Skip to main content
Blog

Web

JWT and token mistakes: signing, expiry, storage and revocation

For token-based APIs, the expertise checks the signing algorithm, key strength, expiry and what happens after logout. Common mistakes and how to fix them in Laravel, Node and Flutter.

· 10 min read

Photo: Tony Webster from Minneapolis, Minnesota, United States, CC BY 2.0 · Wikimedia Commons

A mobile app or SPA has to send the server proof of "who I am" with every request. Most often that proof is a token: a JWT (JSON Web Token) or a random string stored on the server. Tokens are convenient, but a token is really a key: whoever holds it acts as that user. That is why how a token is created, verified, stored and revoked is a central part of security.

In this article we go through common mistakes with JWTs and other tokens, how to test for them yourself, and how to fix them in Laravel (Sanctum, Passport), Node and Flutter.

JWT in brief

A JWT has three parts separated by dots: header.payload.signature. The header and payload are plain JSON encoded with Base64URL, which means they are not encrypted. Anyone can read them:

echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | base64 -d
# {"alg":"HS256","typ":"JWT"}

The signature guarantees the token has not been modified. With HS256 it is created with a shared secret; with RS256 or ES256 it is created with a private key and verified with the public key.

First takeaway: do not put sensitive data in the payload (passwords, passport numbers, lists of internal IDs). The token lives on the device, in the browser and in logs.

Why this matters for the expertise

During the expertise, the specialist captures a token and looks for answers to a few questions: can the signature be bypassed, can the key be recovered, how long does the token live, does it still work after logout, where is it stored? In a mobile app review, a token stored unencrypted on the device is reported as a separate finding — in the expertise of our own app, the high-severity finding "sensitive data stored unencrypted in internal storage" belonged to exactly this class.

Mistake 1: alg: none and algorithm switching

The JWT standard includes a none algorithm — a token with no signature. Some old or misconfigured libraries trust the alg value in the header: if an attacker sets "alg":"none" and strips the signature, the token is accepted.

The second variant is algorithm confusion. The server uses RS256, but the verification function takes the algorithm from the token. The attacker switches alg to HS256 and signs the token with HMAC using the server's public key. If the library uses that public key as the HMAC secret, the forged token verifies.

There is one fix: pin the allowed algorithm on the server and never trust the token's alg.

// Node, jsonwebtoken
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'https://api.your-domain.uz',
  audience: 'mobile-app',
});

In PHP, current versions of firebase/php-jwt require the key together with its algorithm:

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));

To test: take your own test token, replace the header with {"alg":"none","typ":"JWT"}, change the user ID in the payload, leave the signature empty (header.payload.) and send it to the API. The response should be 401.

Mistake 2: a weak secret

If the secret used to sign HS256 tokens is short or guessable (secret, 123456, the project name, a sample value from the docs), it can be cracked offline from a single real token. Common tools such as hashcat do this, and the server never notices. Once the key is found, the attacker can mint tokens for any user, including an admin.

The fix:

  • The secret should be at least 256 random bits: openssl rand -base64 48.
  • It lives in an environment variable or a secrets store, not in code or the repository — see the article on managing secrets.
  • If several services need to verify tokens, use an asymmetric algorithm (RS256, ES256): verifiers only get the public key.
  • If the key has leaked or you suspect it has, rotate it. That invalidates all tokens, so plan the rotation procedure in advance.

Mistake 3: tokens that never expire

A JWT without an exp claim, or an API token with no expiry, works forever. A lost phone, a token that ended up in a log, an employee who left — it keeps working.

A practical approach: a short-lived access token (for example 15 minutes to an hour) and a longer-lived but controlled refresh token. Pick lifetimes to fit the business; what matters is that a limit exists and the server enforces it. Along with exp, verify iss (who issued it) and aud (who it is for).

Mistake 4: storing the token in the wrong place

In the browser. Any JavaScript on the page can read localStorage and sessionStorage. One XSS on the site and the token is stolen. Where possible, keep a browser app's token in a cookie with HttpOnly, Secure and SameSite attributes — JavaScript cannot read it. Do not forget CSRF protection in that case.

In a mobile app. Keeping the token in SharedPreferences, NSUserDefaults or a plain file is a frequent expertise finding. The right place is the Android Keystore and the iOS Keychain. In Flutter:

const storage = FlutterSecureStorage(
  aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
await storage.write(key: 'access_token', value: token);

// logout
await storage.deleteAll();

More detail: Keystore and Keychain.

In logs. The Authorization header or a ?token= in the URL ends up in access logs. Do not send tokens in the query string, and mask headers in logs.

Mistake 5: refresh tokens are not rotated

A refresh token lives long, which makes it the most valuable target. If it stays the same every time, a stolen copy keeps working in parallel with the owner indefinitely.

The right scheme is rotation: on every refresh the old refresh token is invalidated and a new one is issued. If an already-used refresh token shows up again, that is a sign of theft: revoke every token in that chain and ask the user to sign in again.

async function refresh(oldToken) {
  const record = await db.refreshToken.findUnique({ where: { hash: sha256(oldToken) } });
  if (!record || record.expiresAt < new Date()) throw new Unauthorized();
  if (record.usedAt) {
    await db.refreshToken.updateMany({ where: { familyId: record.familyId }, data: { revoked: true } });
    throw new Unauthorized();
  }
  await db.refreshToken.update({ where: { id: record.id }, data: { usedAt: new Date() } });
  return issueTokens(record.userId, record.familyId);
}

Note that the database stores a hash of the refresh token, not the token itself.

Mistake 6: logout does not revoke the token

A JWT is self-contained (stateless): the server verifies it without looking at the database. So "logout" often just deletes the token from the app, while on the server it keeps working until it expires. The expertise tests this simply: a token obtained before logout is used for a request after logout.

Options:

  • Keep access tokens short-lived and revoke the refresh token on the server at logout.
  • Add a jti (token ID) to the payload and keep revoked IDs in a cache (Redis) until they expire.
  • When the password changes, revoke all of the user's sessions and tokens.

To test:

TOKEN=<token from before logout>
curl -s -X POST https://api.your-domain.uz/v1/logout -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w "%{http_code}\n" https://api.your-domain.uz/v1/me \
  -H "Authorization: Bearer $TOKEN"
# expected: 401

Laravel: notes on Sanctum and Passport

Sanctum API tokens are not JWTs: they are random strings stored in the database (personal_access_tokens) as hashes. So problems like algorithm confusion do not apply, and deleting a token takes effect immediately. But check two things:

// config/sanctum.php — no expiry by default (null)
'expiration' => 60 * 24, // in minutes

// logout: delete the current token
$request->user()->currentAccessToken()->delete();

Schedule php artisan sanctum:prune-expired to clean up expired records. For an SPA, use Sanctum's cookie-based session mode — the token never lands in browser storage.

Passport is a full OAuth2 server that issues JWT access tokens signed with RS256. Set lifetimes explicitly and protect the key files:

Passport::tokensExpireIn(now()->addMinutes(30));
Passport::refreshTokensExpireIn(now()->addDays(14));

storage/oauth-private.key must not end up in the repository, file permissions should be restricted (chmod 600), and the production key should be supplied through an environment variable.

A short self-check plan

  1. Sign in with a test account and grab the token. Decode the payload: does it contain sensitive data? Are exp, iss, aud present?
  2. Send a request with an alg: none token and with a tampered signature — expect 401.
  3. Change the user ID in the payload — expect 401.
  4. Send a request with an expired token.
  5. After logout, try the old access and refresh tokens.
  6. Use the same refresh token twice.
  7. Check where the token is stored: in the browser via DevTools → Application, in the mobile app in its storage.

A token is a key. The shorter it lives, the better it is stored and the faster it can be revoked, the less damage it does when stolen.

Checklist

  • The server pins the allowed algorithm; none is rejected.
  • The HS256 secret is at least 256 random bits and not in code; asymmetric keys where possible.
  • Every token has an expiry; exp, iss, aud are verified.
  • No sensitive data in the payload.
  • In the browser the token is in an HttpOnly cookie; in the mobile app it is in Keystore/Keychain.
  • Refresh tokens rotate on every use, and reuse is detected.
  • Logout and password changes revoke tokens on the server.
  • Sanctum has expiration set; Passport keys are protected.

Conclusion

Token mistakes rarely need a sophisticated attack: more often there is no expiry, logout does not work, or the token sits somewhere unprotected. All of this can be checked with a handful of requests — and that is exactly what the expertise does.

Want your authentication and token handling reviewed before the expertise? Request a security scan — the Claude AI agent analyses your code and configuration, and our specialist confirms the findings and helps with the fixes.