JWT: Structure, Claims, Key Management, and Algorithm Confusion

Break down JWT structure (header, payload, signature), the standard claims that govern trust (exp/iss/aud/sub/iat), how signing keys are managed and rotated, the algorithm confusion attack and its defense, and the tradeoffs between short-lived stateless tokens and revocable, storage-backed ones.

JSON Web Tokens (JWTs) showed up throughout the last three lessons -- as the format of an OIDC ID token, and as a common way to represent an OAuth access token -- without a full explanation of what's actually inside one or how it can go wrong. A JWT is a compact, URL-safe, self-contained way to represent a signed set of claims: once issued, anyone holding the right public key (or shared secret) can verify it wasn't tampered with, without a database lookup. That same self-contained-ness is also the source of JWTs' sharpest edges -- a subtly wrong verification implementation can be tricked into accepting a forged token, and a stolen-but-still-valid JWT is hard to invalidate early. This lesson dissects the format, the claims, the algorithm confusion attack in detail, and the revocation/storage tradeoffs every JWT-based system has to make a deliberate decision about.

Structure: Header, Payload, Signature

A JWT is three base64url-encoded segments joined by dots: header.payload.signature. The header is a small JSON object declaring the token type and the signing algorithm, typically {"alg": "RS256", "typ": "JWT"}. The payload is a JSON object of claims -- the actual data the token asserts, such as sub (subject), exp (expiry), and any application-specific claims like role or email. The signature is computed over the first two segments (BASE64URL(header) + '.' + BASE64URL(payload)) using the algorithm named in the header, and is what lets a verifier detect any tampering with either the header or the payload.

It's essential to understand what base64url encoding does and does NOT provide: it is an ENCODING, not encryption -- anyone can decode the header and payload of any JWT they get their hands on with nothing more than a text editor and a base64 decoder, no key required. A JWT's signature guarantees INTEGRITY (the claims haven't been altered since signing) and, depending on the algorithm, AUTHENTICITY (it was signed by a holder of the private/secret key) -- it does not provide confidentiality. Never put secret data (passwords, raw credit card numbers, anything sensitive) directly in a JWT payload, because any party who ever sees the token can read it.

Two families of signing algorithms are in common use: symmetric (HMAC-based, e.g. HS256) where the SAME secret is used to both sign and verify -- meaning every party that can verify a token can also forge one, so this only makes sense when signer and verifier are the same trusted service. And asymmetric (RSA or ECDSA-based, e.g. RS256, ES256) where a PRIVATE key signs and the corresponding PUBLIC key verifies -- many parties can safely verify tokens (they only need the public key) without being able to forge new ones (which requires the private key). This distinction is exactly what the algorithm confusion attack, covered later in this lesson, exploits when a verifier isn't careful.

• JWT = base64url(header) + '.' + base64url(payload) + '.' + base64url(signature). • Header: declares alg (signing algorithm) and typ ("JWT"). • Payload: the claims -- the actual asserted data, readable by anyone without a key (encoding, not encryption). • Signature: computed over header+payload; proves integrity and (for asymmetric algorithms) authenticity. • Symmetric (HS256): same secret signs and verifies. Asymmetric (RS256/ES256): private key signs, public key verifies.

Standard Claims: exp, iss, aud, sub, iat

A handful of claim names are standardized (registered in RFC 7519) specifically because verifiers need to check them consistently across systems. exp (expiration time, a Unix timestamp) is the single most important one to actually enforce -- a JWT with no expiry check is valid forever, no matter how it was originally intended to be used. iat (issued at) records when the token was created, useful for auditing and for computing a token's age even if exp is generous. nbf (not before) marks a token as invalid until a given time, useful for pre-issued but not-yet-active tokens.

iss (issuer) identifies who created the token -- exactly the same role as in the OIDC lesson, and equally important to check explicitly rather than assume: a verifier that trusts multiple issuers' keys must confirm THIS token actually came from the issuer it expects for this operation. aud (audience) identifies who the token is intended for -- again mirroring the OIDC discussion, and equally mandatory to check: a token minted for one internal service should not be silently accepted by a completely different one just because both happen to trust the same signing key.

sub (subject) identifies the principal the token is about -- typically a stable user or client ID. Beyond these registered claims, applications routinely add their own ('private' claims in RFC 7519's terminology) like role, permissions, or tenant_id -- exactly the RBAC/ABAC attributes from earlier lessons, now carried inside the token itself so a resource server can make authorization decisions without a database round-trip. The tradeoff: claims baked into an already-issued token are frozen until it expires -- if a user's role changes mid-token-lifetime, the old token still asserts the old role until it expires or is revoked.

• exp: expiration timestamp -- must be checked on every verification; the single most load-bearing claim. • iat / nbf: issued-at and not-before timestamps, for auditing and delayed activation. • iss: who issued the token -- verify it matches the expected issuer, don't assume. • aud: who the token is intended for -- verify this token's audience matches the verifying service. • sub: the principal (user/client) the token is about; apps often add private claims (role, tenant_id) too.

Key Management and Rotation

A signing key that's compromised, or simply old enough that rotating it is good hygiene, cannot be swapped instantly without breaking every token still in flight if there's only ever one key. Real systems solve this with a key ID (kid) in the JWT header, identifying WHICH of potentially several currently-valid keys was used to sign this particular token. A verifier looks up the kid in its known key set (fetched from a JWKS endpoint, as covered in the OIDC discovery lesson) and uses the matching key -- which means an issuer can introduce a NEW signing key, start using it for newly issued tokens, while still successfully verifying older tokens signed with the PREVIOUS key, as long as that previous key stays in the published key set until every token it ever signed has expired.

A safe rotation sequence looks like: publish the new key alongside the old one in the JWKS (so verifiers can already recognize it); start signing new tokens with the new key; wait until the old key's longest-lived outstanding token has expired; remove the old key from the JWKS. Removing a key too early breaks verification for any still-valid token signed with it; removing a genuinely compromised key immediately is sometimes the right tradeoff anyway, accepting that legitimate outstanding tokens will also fail early -- which is exactly the kind of decision that makes short token lifetimes valuable, since it shrinks that entire exposure window.

For symmetric (HS256) signing, 'key management' also means keeping the shared secret out of source control, rotating it periodically, and, critically, never letting that secret be guessable or derivable from something public -- which is the exact mechanism the next section's attack abuses when a verifier is misconfigured.

• kid (key ID) in the header identifies which of several currently-valid keys signed this token. • Rotation: publish new key -> sign new tokens with it -> wait for old-key tokens to expire -> retire old key. • Removing a key too early breaks verification of any still-outstanding token signed with it. • Short token lifetimes shrink the exposure window if a key ever needs to be revoked immediately (e.g. after compromise).

Algorithm Confusion: Forging a Token by Switching alg

Algorithm confusion (also called 'alg confusion' or the 'none algorithm' attack, depending on the variant) is a well-documented, real-world JWT vulnerability class that comes from a verifier trusting the alg field INSIDE the token it's supposed to be verifying, instead of deciding independently which algorithm it expects. There are two classic variants. The first: an attacker sets alg to "none" in the header and supplies an empty signature -- some naive JWT libraries, when told to 'verify using whatever alg the token says', will interpret none as 'no signature required' and accept the token as valid, meaning ANYONE can forge ANY claims with zero cryptographic knowledge at all.

The second, more subtle variant: an application uses RS256 (asymmetric) for its real tokens and publishes its RSA PUBLIC key openly (which is completely normal and expected -- that's the point of a public key). An attacker crafts a new token with the header changed to alg: HS256 (a SYMMETRIC algorithm) and signs it using the server's own, publicly-known RSA public key AS IF IT WERE an HMAC shared secret. If the server's verification code blindly does 'read alg from the token header, then verify using that algorithm', it ends up calling HMAC-verify with the public key as the secret -- and since the attacker also used that exact public key as their HMAC secret when forging the signature, the check passes. The attacker has just forged a validly-verifying token using only PUBLICLY available information, entirely defeating the asymmetric signing scheme.

The defense in both cases is the same principle: the verifier must decide, independently and in advance, which specific algorithm (and which specific key) it expects for this token -- never take the algorithm from the untrusted token itself as an instruction for how to verify it. A verifier configured to expect RS256 with a specific public key should REJECT any token whose header says anything else, including none or HS256, before even attempting a signature check. Every well-maintained JWT library exposes an explicit 'allowed algorithms' parameter for exactly this reason -- always pass it explicitly, and never leave it to 'whatever the token claims'.

• 'alg: none' attack: a verifier that honors alg=none from the token accepts an unsigned, fully attacker-controlled token. • RS256 -> HS256 confusion: attacker signs a forged token with the server's own PUBLIC key, used as an HMAC secret. • Root cause: verifier lets the untrusted token's own alg header decide how it gets verified. • Defense: pin the expected algorithm (and key) on the VERIFIER side, independent of the token, and reject anything else outright.

Revocation and Storage Tradeoffs

A JWT's core appeal -- verify with no database lookup -- is also its core limitation: because verification never checks anything beyond the token's own signature and claims, there is no built-in way to invalidate a specific still-unexpired token early. A user who logs out, an admin who wants to force-revoke a compromised session, or an account whose role just changed cannot make an already-issued JWT stop working until it naturally expires, unless the system does something extra beyond plain JWT verification.

The two standard mitigations trade off statelessness for revocability. Short expiry + refresh tokens: keep access-token lifetimes very short (minutes), issued alongside a longer-lived refresh token that's checked against server-side state (a database row that CAN be revoked) on every use. This bounds the exposure window of a leaked access token to at most its short lifetime, while pushing all actual revocation logic onto the refresh token's server-side check -- the access token itself is still just as unrevocable as ever, but it doesn't matter much because it expires quickly anyway. A denylist (or 'blocklist'): maintain a server-side store (often Redis, for fast lookups) of tokens or token IDs (jti claim) that have been explicitly revoked before their natural expiry, and check every incoming token against it. This restores the ability to revoke instantly, but reintroduces exactly the shared-state lookup on every request that stateless JWTs were meant to avoid in the first place -- at that point, a JWT with a denylist check is architecturally closer to a traditional session than to a 'pure' stateless token.

Where the token is STORED on the client matters just as much as how it's verified server-side. A JWT held in localStorage is readable by any JavaScript running on the page -- including an attacker's, via a successful XSS payload -- and has no built-in expiry enforcement by the browser itself. A JWT held in an HttpOnly, Secure, SameSite cookie is inaccessible to JavaScript entirely (closing off the XSS-theft vector) but then inherits cookie-specific concerns, chiefly CSRF, which has to be defended against separately (e.g. with a SameSite policy and/or a CSRF token). There is no storage location that's free of tradeoffs -- the choice has to match the specific threat model of the application.

• Plain JWT verification has no built-in early revocation -- it only checks signature and claims like exp, never external state. • Short-lived access token + longer-lived, server-checked refresh token: bounds exposure, pushes revocation to the refresh check. • Denylist of revoked token IDs (jti): restores instant revocation, but reintroduces a per-request shared-state lookup. • localStorage: vulnerable to theft via XSS. HttpOnly cookie: immune to XSS theft, but requires separate CSRF defenses.

Decoding (not verifying) a JWT's structure

Decoding the header/payload requires no key or secret -- this is why a JWT must never carry confidential data in its claims. Decoding is NOT verifying -- an application must never trust decoded claims until the signature has been independently checked against an expected algorithm and key. Never build production JWT handling from scratch like this -- use a vetted library (e.g. firebase/php-jwt); this is shown to make the mechanics explicit.

<?php
declare(strict_types=1);

function base64UrlDecode(string $data): string
{
    return base64_decode(strtr($data, '-_', '+/'));
}

[$headerB64, $payloadB64, $signatureB64] = explode('.', $jwt, 3);

$header  = json_decode(base64UrlDecode($headerB64), true);  // e.g. ['alg' => 'RS256', 'typ' => 'JWT']
$payload = json_decode(base64UrlDecode($payloadB64), true); // the claims -- readable with NO key at all

Code Examples

The critical line checks the header's alg against a hardcoded expectation ('HS256') BEFORE doing anything else -- a forged token claiming alg=none is rejected immediately, never reaching the signature-comparison code at all. This is the correct shape: the verifier, not the token, decides which algorithm is acceptable. Expected result: Unexpected algorithm: none if (($header['alg'] ?? null) !== 'HS256') { throw new JwtVerificationException(...); }

Common mistake: A verifier that instead does 'if header.alg === "HS256", hmac-verify; else if header.alg === "RS256", rsa-verify' -- branching on the TOKEN's own claimed algorithm -- is exactly the algorithm confusion vulnerability: it lets whoever crafted the token choose which verification code path runs.

This deliberately-flawed function shows the mechanism of algorithm confusion concretely: because it branches on $header['alg'] -- data supplied entirely by whoever crafted the token -- an attacker gets to choose which verification code path runs, and can pick the HS256 branch specifically because they can obtain the server's public RSA key text to use as a forged HMAC secret.

$valid = match ($header['alg'] ?? null) { 'HS256' => ..., 'RS256' => ..., default => false };

Common mistake: Supporting multiple algorithms in one verification function, dispatched on the untrusted token's own alg field, is the algorithm confusion vulnerability itself -- not a hypothetical edge case. The fix is to have ONE verification function per expected algorithm+key, chosen by the SERVER's configuration, never by the token.

<?php
declare(strict_types=1);

final class JwtVerificationException extends RuntimeException {}

final class Jwt
{
    private static function base64UrlDecode(string $data): string
    {
        return base64_decode(strtr($data, '-_', '+/'));
    }

    /**
     * Verifies an HS256 JWT against a specific, pre-known secret -- the
     * caller decides the algorithm and key in advance; the token's own
     * "alg" header is checked against that expectation, never trusted
     * as an instruction for how to verify.
     */
    public static function verifyHs256(string $jwt, string $expectedSecret, string $expectedIssuer, string $expectedAudience): array
    {
        $parts = explode('.', $jwt);
        if (count($parts) !== 3) {
            throw new JwtVerificationException('Malformed token.');
        }
        [$headerB64, $payloadB64, $signatureB64] = $parts;

        $header = json_decode(self::base64UrlDecode($headerB64), true);

        // THE KEY DEFENSE: reject outright if the token's alg is anything
        // other than exactly what we expect -- "none", "HS384", "RS256",
        // all rejected here, regardless of what the token itself claims.
        if (($header['alg'] 
…
<?php
declare(strict_types=1);

// DO NOT USE -- this function exists only to show WHY it's dangerous.
// It brands itself as "flexible" by supporting multiple algorithms, but
// that flexibility is exactly the vulnerability.
function insecureVerify(string $jwt, string $hmacSecret, string $rsaPublicKeyPem): array
{
    [$headerB64, $payloadB64, $signatureB64] = explode('.', $jwt, 3);
    $header = json_decode(base64_decode(strtr($headerB64, '-_', '+/')), true);

    $signingInput = "{$headerB64}.{$payloadB64}";
    $signature    = base64_decode(strtr($signatureB64, '-_', '+/'));

    // VULNERABLE: the token's OWN header decides which code path verifies it.
    $valid = match ($header['alg'] ?? null) {
        'HS256' => hash_equals(hash_hmac('sha256', $signingInput, $hmacSecret, true), $signature),
        'RS256' => openssl_verify($signingInput, $signature, $rsaPublicKeyPem, OPENSSL_ALGO_SHA256) === 1,
        default => false,
    };

    if (!$valid) {
        throw new RuntimeException('Invalid signature.');
    }

    return json_decode(base64_decode(strtr($payloadB64, '-_', '+/')), true);
}

// An attacker who knows the server's PUBLIC RSA key (which is meant to be
// public
…

JWT structure and where each defense applies

Header and payload are always readable; only the signature check (with a server-pinned algorithm) establishes trust.

eyJhbGciOiJSUzI1NiJ9 . eyJzdWIiOiI0MiIsImV4cCI6...  . MEUCIQDx...
  \__________________/   \____________________/    \____________/
       HEADER                   PAYLOAD                SIGNATURE
   {alg, typ, kid}         {sub, iss, aud, exp,       computed over
                            iat, ...custom claims}     header+payload

  Verifier checks, in order:
   1. alg matches what WE expect (pinned server-side)  <- stops alg confusion
   2. signature valid for that alg + the correct key (by kid)
   3. exp not passed, nbf reached
   4. iss equals expected issuer
   5. aud contains this verifier
   -> only then are payload claims trusted

Important Rules

A JWT's header and payload are merely encoded, not encrypted -- never place secret data in the claims.

Always pin the expected signing algorithm on the verifier side; never branch verification logic on the token's own alg header.

Check exp, iss, and aud on every verification -- a signature check alone is not a complete verification.

Use a key ID (kid) to support rotation: publish the new key alongside the old until every old-key token has expired, then retire it.

Choose a revocation strategy deliberately: short expiry + refresh tokens, or a denylist -- 'we'll never need to revoke early' is rarely true in production.

Never hand-roll JWT signing/verification for production systems -- use a vetted, actively maintained library.

Where This Has Gone Wrong in Production

The RS256-to-HS256 algorithm confusion attack was demonstrated against real, widely-used JWT libraries around 2015 (affecting libraries in Node.js, Python, PHP, and others that supported 'flexible' multi-algorithm verification without requiring the caller to pin an expected algorithm) and remains a standard item in security audits and OWASP guidance on JWT today. In response, essentially every actively maintained JWT library (including firebase/php-jwt in PHP) now requires the caller to pass an explicit, allowed algorithm list to its verify function, refusing to infer it from the token -- exactly the defense demonstrated in this lesson's first example. Auth0, Okta, and other identity providers explicitly document 'always specify the expected algorithm' as a top integration requirement for exactly this reason.

$alg = $decodedHeader['alg']; $valid = ($alg === 'HS256') ? hmacVerify(...) : rsaVerify(...); // decided by the TOKEN

$valid = hmacVerify($token, $serverConfiguredSecret); // the server ALWAYS expects HS256 specifically -- alg is checked against that fixed expectation and any mismatch is rejected before verification even runs

Letting the untrusted token's own alg header select the verification code path is precisely the algorithm confusion vulnerability -- it hands the attacker the choice of which cryptographic check (if any, in the alg=none variant) gets applied to their own forged token.

What does base64url-encoding a JWT's payload actually provide? A. Confidentiality -- only someone with the signing key can read the claims B. Encoding only -- anyone can decode and read the claims without any key; only the signature is cryptographically protected C. Compression, to keep tokens small D. Automatic expiry enforcement

B. Encoding only -- anyone can decode and read the claims without any key; only the signature is cryptographically protected — Base64url is a reversible encoding, not encryption -- the header and payload of any JWT can be decoded by anyone with a text editor. The signature provides integrity/authenticity, not confidentiality of the claims themselves.

What is the core mechanism of the RS256-to-HS256 algorithm confusion attack? A. The attacker steals the server's private RSA key B. A verifier that trusts the token's own alg header ends up HMAC-verifying with the server's PUBLIC key used as the shared secret, which the attacker also used to forge the signature C. The attacker brute-forces the RSA private key D. The attacker exploits a buffer overflow in the base64 decoder

B. A verifier that trusts the token's own alg header ends up HMAC-verifying with the server's PUBLIC key used as the shared secret, which the attacker also used to forge the signature — Because RSA public keys are meant to be public, an attacker can obtain one and use it as an HMAC secret; if the verifier blindly follows the token's claimed alg (switched to HS256) instead of pinning the expected algorithm, the forged HMAC signature verifies successfully.

What is the recommended defense against algorithm confusion attacks? A. Encrypt the JWT payload in addition to signing it B. The verifier pins the expected algorithm (and key) in advance and rejects any token whose header claims a different one, rather than deciding verification logic based on the token's own alg field C. Never use asymmetric algorithms D. Increase the token's expiry time

B. The verifier pins the expected algorithm (and key) in advance and rejects any token whose header claims a different one, rather than deciding verification logic based on the token's own alg field — The vulnerability exists specifically because verification logic was chosen based on untrusted, attacker-controlled input (the token's alg header); pinning the expected algorithm server-side removes that choice from the attacker entirely.

Why is early revocation difficult for a plain, stateless JWT? A. JWTs cannot be decoded once issued B. Verification only checks the signature and claims like exp against the token itself, with no server-side lookup to consult about whether that specific token has since been revoked C. JWTs are always encrypted, so the server can't read them anymore D. JWTs expire immediately after issuance

B. Verification only checks the signature and claims like exp against the token itself, with no server-side lookup to consult about whether that specific token has since been revoked — Because valid stateless verification never queries any shared state, there's nothing to update to invalidate one specific still-unexpired token -- mitigations (short expiry + refresh tokens, or a denylist) each reintroduce some server-side state to regain that ability.

Extend Jwt::verifyHs256() to also check a jti (JWT ID) claim against a provided denylist (an array of revoked jti strings), so a token that is otherwise perfectly valid (correct alg, signature, exp, iss, aud) is still rejected if its jti has been explicitly revoked.

public static function verifyHs256(string $jwt, string $expectedSecret, string $expectedIssuer, string $expectedAudience, array $revokedJtis = []): array { /* ...existing checks... */ if (in_array($claims['jti'] ?? null, $revokedJtis, true)) { throw new JwtVerificationException('Token has been revoked.'); } return $claims; }

Add a $revokedJtis array parameter; after all the existing checks pass, add: if (in_array($claims['jti'] ?? null, $revokedJtis, true)) { throw new JwtVerificationException('Token has been revoked.'); } before returning $claims.