- Authentication
- JWT
- Security
JWT and Session Security Pitfalls, and How to Avoid Them
The JWT and session mistakes that lead to account takeover: algorithm confusion, weak secrets, missing claim checks, token storage, rotation and real logout.
· 7 min read · Lina Source LLC
JSON Web Tokens are a reasonable format with a long list of sharp edges. The token itself is rarely the problem. The bugs live in how it is verified, where it is stored, how long it lives and what happens when a user logs out. Each of those mistakes has the same outcome: someone holds a token they should not, and your server accepts it.
The two weaknesses that come up most are CWE-347, improper verification of a cryptographic signature, and CWE-613, insufficient session expiration. The examples below use jose, a widely used JavaScript library for JWTs that runs in Node.js, edge runtimes and browsers.
Decoding is not verifying
The most direct form of CWE-347 is reading claims from a token without checking its signature. Every JWT library has a decode function for debugging, and it shows up in auth middleware more often than it should. A decoded token is just base64 that anyone can write. In JavaScript codebases the pattern often hides in a small helper that splits the token on dots and runs JSON.parse on the middle part. It works in every test, because the test tokens are valid, and it accepts every forged token in production.
import { decodeJwt, jwtVerify } from 'jose';
// Vulnerable: anyone can mint a token with sub set to any user ID
const claims = decodeJwt(token);
const userId = claims.sub;
// Correct: the signature, algorithm and claims are checked first
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;alg none and algorithm confusion
The JWT header says which algorithm signed the token, and the attacker controls the header. Two classic attacks follow from trusting it. The first is alg set to none: an unsigned token that some older libraries accepted as valid. The second is algorithm confusion: a server that expects RS256 but lets the header pick the algorithm can be handed an HS256 token signed with the server's public key used as an HMAC secret. The public key is public, so the attacker can sign anything.
Modern libraries defend against both, and jose rejects unsecured tokens in jwtVerify and checks that the key type matches the algorithm. Do not rely on defaults alone. Pin the algorithm list explicitly on every verify call, so a future refactor or library swap cannot quietly widen it. If you accept more than one algorithm, for example during a key migration, list both explicitly and use a separate key for each. When you rotate keys, put a kid in the header and select the key by kid from your own list, never from a URL named in the token header.
Weak signing secrets
An HS256 token is signed with a shared secret. If the secret is short or guessable, such as 'secret', the app name or a value copied from a tutorial, an attacker who has a single valid token can brute-force it offline with commodity tools and then sign tokens for any user. There is no rate limit on offline guessing.
- Use at least 32 random bytes for HS256, generated with something like openssl rand -base64 32.
- Load the secret from the environment or a secret manager, and fail at startup if it is missing or short.
- Never commit it, and rotate it if it ever was committed.
- If several services need to verify tokens but only one should issue them, use an asymmetric algorithm such as RS256 or EdDSA so verifiers hold only the public key.
Validate exp, aud and iss
A valid signature only proves who issued the token. The claims decide whether it is meant for you and whether it is still current. A token without an expiry is valid forever. A token issued for your mobile API should not be accepted by your admin service just because both trust the same identity provider. That is what aud and iss are for.
import { SignJWT, jwtVerify } from 'jose';
const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';
export function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ tv: tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp', 'sub'],
});
return payload;
}jose checks exp whenever it is present, but a token without exp would otherwise pass. The requiredClaims option closes that gap. For tokens from an external identity provider, verify against its published key set with createRemoteJWKSet and still pin algorithms, issuer and audience.
localStorage or httpOnly cookies
Storing a token in localStorage makes it readable by any script on the page. One XSS bug, or one compromised third-party script, and the token can be sent to an attacker and used from anywhere until it expires.
An httpOnly cookie cannot be read by JavaScript. XSS is still serious, because injected script can make requests as the user while the page is open, but it cannot steal a long-lived credential and replay it later. For browser apps talking to their own backend, cookies are the better default. For a single-page app calling a separate API, keeping the access token only in memory and the refresh token in an httpOnly cookie is a reasonable middle ground.
// Express: session cookie with safe attributes
res.cookie('__Host-session', accessToken, {
httpOnly: true, // not readable from JavaScript
secure: true, // HTTPS only; required by the __Host- prefix
sameSite: 'lax', // not sent on cross-site POSTs
path: '/', // required by the __Host- prefix
maxAge: 10 * 60 * 1000, // milliseconds, matches the token lifetime
});The __Host- prefix tells the browser to reject the cookie unless it is Secure, has path set to /, and has no Domain attribute, which stops a compromised subdomain from overwriting it.
SameSite and what it does not cover
SameSite=Lax blocks the cookie on cross-site POST requests and on subresource loads, which removes most classic CSRF. It still sends the cookie on top-level GET navigations, so any GET endpoint that changes state is exposed. Strict blocks those too, but also logs users out when they follow a link to your app from email or another site. None disables the protection and requires Secure.
Lax plus a rule that GET requests never change state is a sound baseline. For sensitive mutations, add an Origin header check or a CSRF token. Remember that SameSite treats all subdomains of your registrable domain as same-site, so a vulnerable subdomain can still forge requests.
Rotation, revocation and real logout
A stateless JWT cannot be revoked before it expires; that is the trade-off for not checking a database on each request. CWE-613 describes what happens when that trade-off is ignored: logout, password changes and account suspensions that do not actually end access. Deleting an account, changing a role and removing someone from a team are revocation events too, and each should take effect on the next request, not at the next token expiry.
- Keep access tokens short-lived, in the range of minutes, not days.
- Store refresh tokens server-side, hashed, and rotate them on every use.
- If a refresh token that was already rotated is used again, treat it as theft and revoke the whole token family.
- Add a token version to the user row and to the token; bump it on logout-everywhere, password change or suspension.
- Regenerate the session identifier at login to prevent session fixation.
export async function requireUser(token: string) {
const payload = await verifyAccessToken(token);
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, tokenVersion: true, disabled: true },
});
// A bumped version or a disabled account ends access immediately
if (!user || user.disabled || user.tokenVersion !== payload.tv) {
throw new Error('Session revoked');
}
return user;
}That lookup brings back one database read per request, which is the honest cost of revocation. Many apps are simpler and safer with an opaque session ID in a cookie and a sessions table. Logout then means deleting a row. Use JWTs where their properties actually help, such as short-lived tokens between services, not because they are the default in a tutorial.
Whatever you choose, logout must happen on the server. Clearing the cookie or deleting the token in the browser only removes the user's copy; a stolen copy keeps working until the server refuses it. On logout, delete the server-side session or refresh token, clear the cookie using the same name, path and attributes it was set with, and bump the token version if the user chose to sign out everywhere. A password reset should end every other session as well.
A short review checklist
- Search for decode calls in authentication paths.
- Confirm every verify call pins algorithms, issuer and audience, and requires exp.
- Check how the signing secret is generated, loaded and validated at startup.
- Find where tokens are stored in the browser.
- Test that logout, password change and account suspension end existing sessions.
These checks are mostly about reading code paths end to end, which is also how CodeAuditAgent approaches an audit: findings come with the quoted line, the CWE, an exploit scenario and a patch, so a missing audience check can be confirmed and fixed in minutes.