The "alg=none" Attack — Why It Still Burns Apps in 2026
The JWT specification (RFC 7519) defines an algorithm field in the token header. The valid values include HS256, RS256, ES256, EdDSA — and "none". The "none" algorithm means the token is not signed at all. The original justification was "useful when the integrity of the JWS has already been verified by other means" — meaning, in some pipeline where a different layer guarantees integrity. In practice, that footnote produced one of the most cited vulnerability classes in modern auth.
The classic attack: a developer integrates a JWT library, and the verify function is something like jwt.verify(token, secret). The library, on receiving a token whose header says { "alg": "none" }, looks at the algorithm, sees there is no signature to check, and returns the payload as "verified." An attacker who can craft any token — say, by tampering with one they got legitimately — sets alg to none, removes the signature, and now signs in as anyone. The CVE list still gets fresh additions every year because the attack survives in many languages and many libraries: CVE-2015-9235 (jsonwebtoken), CVE-2018-1000531 (inversoft), CVE-2019-7644 (auth0/java-jwt), CVE-2022-23529 (jsonwebtoken again), and on through 2024.
A second flavor is the "algorithm confusion" attack. RS256 (asymmetric) verifies a signature using a public key. HS256 (symmetric) verifies using a shared secret. If a server stores its RS256 public key and the verify function takes "key" as a generic parameter, an attacker switches the header to alg: HS256 and signs the token using the public key as if it were the HMAC secret. The server then "verifies" using the same key, succeeds, and accepts the forgery. CVE-2016-10555, CVE-2019-20933, and many later issues are this exact pattern.
The defense is both simple and easy to forget. NEVER trust the alg field in the token. The verifier must hard-code the expected algorithm — or at least a strict allowlist — and reject anything else, including (especially) "none". A correct call looks like jwt.verify(token, key, { algorithms: ['RS256'] }), and you must verify in code that this option is actually being passed. Many libraries since around 2020 default to a safe allowlist, but plenty of legacy code in the wild still constructs verifiers without it.
Beyond alg, the verifier must also check exp (not expired), nbf (already valid), iss (issuer matches), aud (audience matches you), and any nonce or jti you care about. A JWT is just signed JSON; the cryptographic signature only proves the bytes have not been changed, not that the bytes mean the right thing for your application.
// VULNERABLE — accepts alg=none and algorithm confusion
const payload = jwt.verify(token, key);
// HARDENED — explicit allowlist + claim checks
const payload = jwt.verify(token, key, {
algorithms: ['RS256'], // never trust header alg
issuer: 'https://api.example.com',
audience: 'https://app.example.com',
clockTolerance: 30, // 30s skew, not minutes
});
// alg=none token an attacker would craft
header = base64url('{"alg":"none","typ":"JWT"}')
payload = base64url('{"sub":"admin","exp":9999999999}')
signature = '' // empty
token = `${header}.${payload}.` // trailing dot, no sig
// Algorithm confusion: signing with the RS256 public key as HMAC key
const pub = fs.readFileSync('public.pem');
const forged = jwt.sign({ sub: 'admin' }, pub, { algorithm: 'HS256' });
// If the verifier doesn't pin algorithms, this passes.
Refresh Tokens Done Right — Rotation, Reuse Detection, Sliding Sessions
Access tokens are short (5–15 minutes), bearer, and verifiable without a database lookup. Refresh tokens are long (days to weeks), exchanged for new access tokens, and unlike access tokens, must be revocable on demand. Designing the refresh flow is where most JWT-based systems quietly fail.
The OAuth 2.1 draft and the IETF "OAuth 2.0 Security Best Current Practice" (RFC 9700, 2024) both mandate refresh token rotation: every time the client exchanges refresh token R for a new access token, the server also issues a new refresh token R' and invalidates R. The single-use property is critical. Without it, a stolen refresh token grants the attacker indefinite access. With rotation, an attacker who steals R uses it once — and the moment the legitimate user's client tries to refresh, the server sees the second use of R, knows something is wrong, and can revoke the entire session family.
This "reuse detection" is the killer feature of rotation. Implement it like this: store a refresh-token family identifier (a UUID per login session) alongside each issued refresh token. Whenever you accept a refresh, mark the old one as used. If a refresh comes in for a token that is already marked used, that means either (a) the legitimate client is replaying due to a network bug, or (b) someone stole one of the tokens. Either way the safe move is the same: invalidate every refresh token in that family, force a fresh login, and log a security event. Auth0, Stripe, Supabase, and basically all major auth providers implement this exact pattern.
Sliding expiration is the second key idea. Instead of a hard deadline ("refresh tokens expire 30 days after issuance"), you set both a max idle window ("must be used within 14 days of last use") and an absolute lifetime ("regardless, expires 90 days after first issuance"). This gives you the UX of "logged in until I stop using the app" without the security horror of refresh tokens that live for years.
A practical note on storage: refresh tokens should NOT be JWTs. JWTs are stateless and self-contained, which is the wrong shape for something you need to revoke instantly. Store refresh tokens server-side as opaque random strings (Base64URL-encoded 32+ random bytes) keyed in a database, with the family ID, the user ID, the device fingerprint, the issuance time, the last-used time, and a "used" flag. Access tokens stay JWTs because they are short-lived enough that revocation matters less; revoking immediately means waiting at most one access-token lifetime. If you genuinely need instant revocation of access too, ditch JWTs entirely and use opaque session IDs with a server lookup on every request — that is what Stripe, GitHub, and most consumer apps actually do.
// Refresh endpoint with rotation + reuse detection
async function refresh(refreshToken) {
const row = await db.refreshTokens.findOne({ token: refreshToken });
if (!row) throw new Unauthorized();
if (row.used) {
// REPLAY: revoke the entire family
await db.refreshTokens.updateMany(
{ familyId: row.familyId },
{ revoked: true }
);
auditLog('refresh-token-replay', { userId: row.userId });
throw new Unauthorized('session compromised');
}
// Mark current as used + idle window check
if (Date.now() - row.lastUsedAt > 14 * DAY) throw new Unauthorized();
if (Date.now() - row.firstIssuedAt > 90 * DAY) throw new Unauthorized();
await db.refreshTokens.update({ id: row.id }, { used: true });
// Issue rotated pair
const newAccess = signJWT({ sub: row.userId }, { expiresIn: '15m' });
const newRefresh = base64url(crypto.randomBytes(32));
await db.refreshTokens.insert({
token: newRefresh,
familyId: row.familyId, // same family, new member
userId: row.userId,
used: false,
firstIssuedAt: row.firstIssuedAt, // PRESERVE absolute lifetime
lastUsedAt: Date.now(),
});
return { accessToken: newAccess, refreshToken: newRefresh };
}
JWE vs JWS — When You Should Encrypt (Not Just Sign)
Most "JWTs" you see in the wild are JWS — JSON Web Signature. The payload is Base64URL-encoded but not encrypted, so anyone who has the token can read every claim by base64-decoding the middle segment. That is fine if the claims are non-sensitive ("user 12345 is logged in, expires at X"), but it is wrong if the claims include things like email addresses, role names, internal user IDs your business cares about, customer plan tiers, or anything you would not want a competitor or a regulator's auditor to read.
JWE — JSON Web Encryption — wraps the same idea as JWS but encrypts the payload too. A JWE token has five segments instead of three (header, encrypted key, IV, ciphertext, authentication tag). Reading the payload requires the decryption key. If you need to ship a token that contains sensitive data through a third party (a webhook receiver, a redirect URL, a mobile client you do not fully trust), JWE is the right tool.
The decision rule is straightforward. Ask: does the holder of this token need to read the claims? If yes, JWS — the holder is your own backend or your own client, and the signature is the integrity guarantee. If no — the token transits through code that should not learn the contents, and only your service decrypts at the end — JWE. A common combined pattern is "nested JWT": sign first with JWS, then wrap that as the JWE plaintext, so the recipient gets both encryption (only they can read) and signature (they can verify the issuer).
Algorithm choices in 2026: for JWS, prefer EdDSA (Ed25519) for new systems — fastest, smallest signatures, no curve-confusion footguns; ES256 (ECDSA P-256) is the broadly-supported fallback. RS256 is fine if you are stuck with PKI infrastructure that demands RSA, but the keys are huge and signing is slow. For JWE, use direct AES-GCM with key wrap (alg=A256KW + enc=A256GCM) for symmetric setups, or ECDH-ES with AES-GCM for the asymmetric case. Avoid RSA-OAEP unless required — it is slow and the key sizes are punishing.
A final caution: encryption is not a substitute for not putting sensitive data in the token at all. Tokens get logged, cached, copy-pasted, screenshotted. The principle of "claims should be the minimum your verifier needs" applies to JWE just as much as JWS. If a claim is too dangerous to leak, the safest place for it is your database, with the JWT carrying only an opaque pointer.
// JWS — payload is readable, just signed
header.payload.signature
^^^^^^^.^^^^^^^.^^^^^^^^^
3 segments, base64url decode the middle to read claims.
// JWE — payload is encrypted (5 segments)
header.encryptedKey.iv.ciphertext.tag
// Node 'jose' library
import { SignJWT, jwtVerify, EncryptJWT, jwtDecrypt } from 'jose';
// JWS sign with EdDSA (recommended new default)
const jws = await new SignJWT({ sub: '123' })
.setProtectedHeader({ alg: 'EdDSA' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(privateKey);
// JWE encrypt with AES-GCM
const jwe = await new EncryptJWT({ ssn: '123-45-6789' })
.setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' })
.setExpirationTime('5m')
.encrypt(symmetricKey);
// Nested: sign then encrypt
const inner = await new SignJWT({ sub: '123', email: 'a@b.com' })
.setProtectedHeader({ alg: 'EdDSA' }).sign(signingKey);
const outer = await new EncryptJWT({ jwt: inner })
.setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' }).encrypt(encKey);
Last updated:
About this tool
A JWT (JSON Web Token) decoder splits a token into its three parts — header, payload, and signature — and decodes the first two from Base64URL JSON so you can read claims like sub, iat, exp, and any custom fields. JWTs are the standard bearer token format for OAuth/OIDC, so you will see them constantly in Authorization headers, cookies, and API responses.
How to use
Paste the JWT — three Base64URL segments separated by dots — into the input.
Read the header (algorithm, key id) and payload (claims) panels.
Watch for the expiration warning if exp is in the past.
Inspect custom claims to confirm scopes, tenant ID, or user info.
Use the signature segment to look up the kid in your JWKS for verification (this tool does not verify — sign with your library to do that).
Common use cases
Debugging an OAuth flow by inspecting the access token claims.
Confirming exp and iat to investigate "token expired" errors.
Reading scope or roles to debug authorisation failures.
Verifying that a custom claim added on the auth server actually arrives at the client.
Inspecting an OIDC id_token to see what user attributes are exposed.
Sharing a redacted JWT decode in a bug report to compare against the spec.
Frequently asked questions
Q. Is decoding the same as verifying?
A. No. Decoding is just Base64URL → JSON; anyone can do it. Verification requires the signing key (HS256 secret or RS256/ES256 public key) and confirms the token has not been tampered with.
Q. Why is my JWT readable? Is that a security problem?
A. JWTs are not encrypted by default. Never put secrets, plaintext passwords, or sensitive PII in a JWT payload. Use JWE (encrypted JWT) if you must hide the contents.
Q. What does the alg=none warning mean?
A. A JWT with alg=none has no signature. Servers must reject these unless explicitly allowed; otherwise an attacker can forge any payload they like.
Q. Where does my browser send the token?
A. Nowhere. This decoder runs entirely in JavaScript inside your browser; the token never leaves the page.