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.

src/auth/currentUser.ts+2 -1
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;}
Illustration written for this page, not from a customer repository. A token's payload is only base64. Anyone can write a token whose payload names another user and another organization, with any signature or none, and the API treats it as that user.

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.

AevralIllustration

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.

src/auth/currentUser.ts (safe version)
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


Security review, handled.

One GitHub App. Reviews start when the App is installed. A Check on each pull request it reviews, with inline comments when there is a grounded finding. Free tier live: public repos free, 500/org/month, 25 private reviews a month. Paid plans are live in the console.

Install the GitHub AppLog in

For professional use. By installing, you confirm you can act for the account or organization that owns it, and you accept the Terms and DPA on its behalf.