JSON Web Tokens are a common way to manage API sessions and trust across multiple services. These notes cover how JWTs work, where developers misuse them, and how common implementation flaws lead to privilege escalation, token forgery, sensitive data disclosure, and cross-service abuse.
APIs became the backbone of many modern applications because the same server-side logic can serve web frontends, mobile apps, and third-party integrations. That also pushed session handling away from browser-centric cookies toward token-based approaches that are easier to transport across different clients.
JWTs are one of the most common attempts to standardize this model. They are useful, but they also push more trust and more cryptographic responsibility into application code.
In token-based session management, the application authenticates the user and returns a token in the response body. Client-side code stores it, often in LocalStorage, and then attaches it to future requests, typically through the Authorization: Bearer header.
JWTs are simply a structured token format for carrying signed claims. They do not automatically make an application secure. They only work safely if claim content, signing, verification, expiry, and scope are all handled correctly.
A JWT has three dot-separated parts, all Base64Url encoded:
The signature is what makes a JWT useful. If the application verifies the signature correctly, it can trust the claims inside the token. If verification is weak or confused, the JWT becomes attacker-controlled input with a trusted appearance.
HS256 use a shared secret to sign and verify.RS256 use a private key to sign and a public key to verify.Security boundary: JWT security is mostly a signature verification problem. If the application trusts the payload before enforcing the right verification path, the rest of the design usually collapses quickly.
Unlike traditional server-side sessions, JWT claims are sent to the client. That means anything included in the payload is visible to whoever possesses the token, even if it is signed.
Common mistakes include embedding password hashes, plaintext passwords, internal infrastructure details, or other server-only values in the claims.
payload = {
"username" : username,
"password" : password,
"admin" : 0,
"flag" : "[redacted]"
}
access_token = jwt.encode(payload, self.secret, algorithm="HS256")
The right model is to store only the minimum claims necessary for trust decisions in the token, then perform sensitive lookups server-side after validating the JWT.
payload = jwt.decode(token, self.secret, algorithms="HS256")
username = payload['username']
flag = self.db_lookup(username, "flag")
If the server decodes a JWT without verifying the signature, the payload is effectively attacker-editable. The user can flip claims such as admin from 0 to 1 and the application will accept the modified token as legitimate.
payload = jwt.decode(token, options={'verify_signature': False})
This is a direct token forgery issue. The fix is simply to verify the token with the appropriate secret or key every time the token is trusted.
payload = jwt.decode(token, self.secret, algorithms="HS256")
JWTs support the None algorithm in the standard. If an application trusts whatever algorithm the token header says and does not explicitly reject None, an attacker may be able to strip the signature and submit an unsigned token.
header = jwt.get_unverified_header(token)
signature_algorithm = header['alg']
payload = jwt.decode(token, self.secret, algorithms=signature_algorithm)
The safe pattern is to define an allowed list of algorithms in server-side code rather than deriving trust policy from attacker-controlled token headers.
payload = jwt.decode(token, self.secret, algorithms=["HS256", "HS384", "HS512"])
If the application signs JWTs with a weak secret and uses a symmetric algorithm like HS256, the secret can often be cracked offline using a captured token and a wordlist. Once the secret is known, the attacker can mint arbitrary tokens with valid signatures.
A common practical workflow is:
hashcat -m 16500 -a 0 jwt.txt jwt.secrets.list
Fix: use a long, random secret generated for machine use, not a human-memorable phrase copied from a tutorial.
Algorithm confusion attacks happen when the server supports both symmetric and asymmetric algorithms and the verification code mixes those trust models together. A common case is downgrading from RS256 to HS256 and tricking the server into treating the public key as an HMAC secret.
If the public key is known, the attacker can sign their own forged token using HS256 with that public key value.
payload = jwt.decode(
token,
self.secret,
algorithms=["HS256", "HS384", "HS512", "RS256", "RS384", "RS512"]
)
The fix is to branch verification logic explicitly based on allowed algorithm families and use the right verifier material for each one.
header = jwt.get_unverified_header(token)
algorithm = header['alg']
payload = ""
if "RS" in algorithm:
payload = jwt.decode(token, self.public_key, algorithms=["RS256", "RS384", "RS512"])
elif "HS" in algorithm:
payload = jwt.decode(token, self.secret, algorithms=["HS256", "HS384", "HS512"])
A signed JWT without a sensible expiry can become a persistent access token. Since JWTs are often self-contained, the server cannot always revoke them as easily as a server-side session unless it introduces a blocklist or separate revocation mechanism.
If the exp claim is missing or set too far into the future, a stolen token may remain valid far longer than intended.
lifetime = datetime.datetime.now() + datetime.timedelta(minutes=5)
payload = {
'username' : username,
'admin' : 0,
'exp' : lifetime
}
access_token = jwt.encode(payload, self.secret, algorithm="HS256")
Short-lived access tokens combined with refresh tokens are often a more resilient design than issuing long-lived bearer tokens directly.
In multi-application environments, the aud claim tells a service which application a token is intended for. If one application fails to enforce the audience claim, it may incorrectly trust a token issued for a different service.
This becomes dangerous when claims such as admin mean different things across services. A user may legitimately be admin in one app but not another, and a missing audience check can turn that into privilege escalation.
payload = jwt.decode(token, self.secret, audience=["appA"], algorithms="HS256")
Cross-service relay: the token is valid cryptographically, but it is valid for the wrong service. Without audience enforcement, the target app accepts trust that was never meant for it.
When reviewing JWT implementations, a practical workflow looks like this:
alg values.exp, aud, and any role or privilege claims for misuse.