Token and session flaws in agent-written code
A login token is only worth something if the server checks who signed it. The function that reads a token without checking is one word away from the one that checks.
Aevral,
In plain words
Think of a festival wristband. The gate staff let you in if you wear one. If they only check the colour, anyone with the right colour of ribbon walks in; the wristband works because the gate checks it was issued by the festival and has not expired. A login token is that wristband. After you sign in, the server hands your browser a token, and every later request is trusted because of it.
Token and session flaws are the ways that check weakens: a token that is read but never verified, a token that never expires, a session that survives logout, or a reset link anyone could guess.
Why agents write it: decode and verify sit side by side
JWT libraries offer two functions with nearby names. In the widely used jsonwebtoken package, verify checks the signature, the expiry and the allowed algorithms; decode only reads the payload, and its documentation warns not to use it for untrusted tokens. Asked to read the user id from a token, or to stop a verify error during a migration to a new login provider, an agent can reach for decode, and every test with a real token still passes.
The same pattern repeats with expiry and sessions. A failing test with an expired fixture token invites ignoreExpiration: true. A logout feature that clears the cookie in the browser looks finished in the UI while the server still accepts the old session.
The shapes it takes in a pull request
Read instead of verified
jwt.decode, a manual base64 split of the token, or a verify call with the signature check turned off, followed by trusting the claims.
Checks loosened to pass a test
ignoreExpiration, a missing algorithms list, or no issuer and audience check, added so that a fixture token or a second provider's token is accepted.
A session that outlives its reason
Logout that clears the cookie but not the server-side session, or a session id kept the same across login, which leaves room for session fixation.
A guessable or reusable reset token
Password reset or magic-link tokens made with Math.random() or a timestamp, with no expiry, or still valid after first use.
Worked example, an illustration
A login-provider migration that swaps verify for decode.
The team is moving to a new login provider that signs tokens with a different key and algorithm. verify started rejecting the new tokens, and the agent swapped it for decode so both old and new tokens work while the migration runs.
export function currentUser(req: Request) { const token = req.headers.authorization?.replace("Bearer ", ""); if (!token) return null; const claims = jwt.verify(token, process.env.JWT_SECRET!, { algorithms: ["HS256"] }) as Claims; // verify() rejects tokens from the new provider; decode accepts both during the migration const claims = jwt.decode(token) as Claims | null; return claims ? { id: claims.sub, orgId: claims.orgId } : null;}What an Aevral finding on it looks like
On a reviewed pull request, a finding is an inline comment pinned to the added line, next to an advisory Check that never blocks the pull request. This is an illustration of that shape for the diff above.
src/auth/currentUser.ts, added line: `const claims = jwt.decode(token) as Claims | null;`
jwt.decode returns the payload without checking the signature or the expiry. currentUser now trusts sub and orgId from any well-formed token, so a caller can sign in as any user by writing their own token.
Missing control: Signature verification against each provider's key, with an explicit algorithm list, issuer and audience.
Fix with your agent
In src/auth/currentUser.ts, currentUser replaced jwt.verify with jwt.decode, so token signatures are no longer checked. Verify tokens from the new provider with jose jwtVerify against its JWKS URL (explicit algorithms, issuer and audience), and keep jwt.verify with HS256 for legacy tokens until the migration ends. Require exp and sub, and check sub and orgId are strings before trusting them. Add tests for a forged payload, an expired token and a token without exp, each expecting null.
Check this finding against the code before changing anything. Make the smallest change that restores the control, add a test for it, and stop before commit or push.A finding is a lead with evidence, not a confirmation. You or your agent check it against the code; Aevral never applies or merges a change. See handing a finding to your coding agent.
How to fix it
A migration needs two verifiers, not zero. Verify new tokens against the new provider's published keys with an explicit algorithm, issuer and audience, and keep verifying legacy tokens with the old secret until the last one expires. Both verifiers also insist on an expiry and check the user and organization claims at runtime, because a correctly signed token without an exp is valid forever. The function becomes async, which is the honest cost of fetching keys.
import { createRemoteJWKSet, jwtVerify } from "jose";
import jwt from "jsonwebtoken";
const NEW_ISSUER = "https://login.example.com/";
const jwks = createRemoteJWKSet(new URL(`${NEW_ISSUER}.well-known/jwks.json`));
type User = { id: string; orgId: string };
// Claims are checked at runtime: a token must expire and name a user and org.
function toUser(claims: unknown): User | null {
const c = claims as Record<string, unknown> | null;
if (!c || typeof c.exp !== "number") return null;
if (typeof c.sub !== "string" || typeof c.orgId !== "string") return null;
return { id: c.sub, orgId: c.orgId };
}
async function verifyNew(token: string): Promise<User | null> {
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: NEW_ISSUER,
audience: "api.example.com",
algorithms: ["RS256"],
requiredClaims: ["exp", "sub"],
});
return toUser(payload);
} catch {
return null;
}
}
function verifyLegacy(token: string): User | null {
try {
return toUser(jwt.verify(token, process.env.JWT_SECRET!, { algorithms: ["HS256"] }));
} catch {
return null;
}
}
export async function currentUser(req: Request): Promise<User | null> {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return null;
return (await verifyNew(token)) ?? verifyLegacy(token);
}What this page does not claim
- Aevral's PR security review, live on install, looks for token and session flaws on the diff of the pull requests it reviews, as one class among several. Looking for a class does not mean finding each instance of it: a review can miss one, and a quiet review is not proof that the code is clean.
- The whole-repo scan reads authorization, IDOR, and business-logic access control only. It does not read token and session flaws; on this class, the pull request is where Aevral looks.
- No class-specific performance evidence for token and session flaws is published. The receipts page reports named runs of the whole PR review eval suite, not a result for this class.
- The diff, the finding and the fix on this page are illustrations written for this page. They are not output from a customer repository.
- This page covers how application code issues and checks tokens and sessions. Password storage, multi-factor setup and the login provider's own configuration are outside it.
Sources
CWE-347: Improper Verification of Cryptographic Signature; CWE-384: Session Fixation; OWASP API2:2023 Broken Authentication; OWASP Session Management Cheat Sheet; jsonwebtoken README (decode does not verify); jose.
Read next
Reviewing a pull request for access control; Cross-site scripting in agent-written code; PR security review.
Other classes