jwt / authentication / cryptography / web-security

How JWT Authentication Works

A deep-dive into JWT structure, signature verification, and the attack vectors that break implementations in production.

July 5, 2026 · 7 min read

JSON Web Tokens are everywhere — API gateways, OAuth flows, session management. They’re also regularly misimplemented in ways that hand attackers full account takeover. This post covers how JWTs actually work, what the spec requires, and where things go wrong.

Structure

A JWT is three base64url-encoded segments joined by dots:

header.payload.signature

Header — declares the token type and signing algorithm:

{
  "alg": "RS256",
  "typ": "JWT"
}

Payload — contains claims. Registered claims (RFC 7519) include iss, sub, aud, exp, nbf, iat, jti. Everything else is application-defined:

{
  "sub": "u_9f3a1b",
  "iat": 1751740800,
  "exp": 1751827200,
  "roles": ["admin"]
}

Signature — computed over base64url(header) + "." + base64url(payload) using the key and algorithm declared in the header.

For RS256:

signature = RSASSA-PKCS1-v1_5-Sign(SHA256(header.payload), privateKey)

The recipient verifies with the corresponding public key. The payload is not encrypted — it’s just encoded. Anyone with the token can read the claims. If you need confidentiality, use JWE (JSON Web Encryption).

Verification Flow

A correct verifier must:

  1. Split the token into three segments.
  2. Base64url-decode the header and parse the JSON.
  3. Validate alg against an allowlist — never trust the header’s algorithm blindly.
  4. Fetch the correct key (by kid if present) from a trusted key store.
  5. Verify the signature over header.payload.
  6. Validate standard claims: exp (not expired), nbf (not before), aud (intended audience), iss (expected issuer).
  7. Only then trust the payload claims.

Step 3 and step 6 are where most implementations fail.

Attack Vectors

Algorithm Confusion (alg: none)

Early JWT libraries accepted "alg": "none" as a valid algorithm, treating unsigned tokens as valid. An attacker could strip the signature, set alg to none, and forge arbitrary payloads.

Fix: Hardcode the expected algorithm server-side. Reject any token whose header alg doesn’t match.

# Wrong — trusts the header
decoded = jwt.decode(token, key, algorithms=jwt.get_unverified_header(token)["alg"])

# Correct — enforces algorithm
decoded = jwt.decode(token, key, algorithms=["RS256"])

RS256 to HS256 Confusion

When a server supports both asymmetric (RS256) and symmetric (HS256) algorithms, an attacker can submit a token with "alg": "HS256" and sign it using the server’s public key as the HMAC secret. If the library picks the algorithm from the token header and the server has the public key loaded, verification passes.

This is a variant of the same root cause: the algorithm must be fixed by the verifier, not negotiated from the token.

JWT Secret Brute-Force

HS256/HS384/HS512 tokens are only as strong as the secret. Weak secrets are trivially brute-forced with tools like hashcat:

hashcat -a 0 -m 16500 token.jwt rockyou.txt

Use secrets with at least 256 bits of entropy for HMAC-signed JWTs. For production systems, prefer RS256 or ES256 — asymmetric schemes separate signing from verification and make secret rotation operationally simpler.

Claim Validation Bypass

The exp, aud, and iss claims are only meaningful if the verifier checks them. Many libraries require explicit opt-in:

# PyJWT — audience and issuer validation must be explicit
jwt.decode(
    token,
    key,
    algorithms=["RS256"],
    audience="api.example.com",
    issuer="https://auth.example.com"
)

If audience and issuer are omitted from the decode call, a token issued for a different service or by a different provider may be accepted.

kid Header Injection

The kid (Key ID) header is used to select which key to verify with. If a library passes kid directly into a database query or file path to fetch the key, injection attacks are possible:

kid: "../../dev/null"       # Empty key → empty HMAC secret → trivially forgeable
kid: "x' UNION SELECT 'attacker-key'--"   # SQL injection to control the key

Fix: Validate kid against a strict allowlist of known key identifiers before any lookup.

Token Lifecycle

Short-lived tokens reduce the blast radius of compromise. Access tokens should expire in minutes to hours; refresh tokens should be rotated on use (refresh token rotation) and stored in httpOnly cookies, not localStorage.

There is no native JWT revocation — the token is valid until exp. If you need revocation (e.g., logout, compromise response), maintain a server-side blocklist keyed on jti. This reintroduces statefulness, but it’s unavoidable for hard revocation requirements.

Key Rotation

For RS256/ES256, publish your public keys via a JWKS endpoint (/.well-known/jwks.json). Each key should have a unique kid. Rotate keys by adding the new key to the JWKS before issuing tokens signed with it, and retiring the old key after its tokens have expired.

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "2026-07",
      "alg": "RS256",
      "n": "...",
      "e": "AQAB"
    }
  ]
}

Summary

The security of JWT hinges almost entirely on the verifier. The token format is a transport mechanism — the trust model depends on:

  • Fixed algorithm enforcement (never from the token header)
  • Strict claim validation (exp, aud, iss)
  • Proper key management and kid sanitization
  • Appropriate secret entropy for HMAC schemes

Get those four right and JWT is a solid, stateless authentication primitive. Skip any one of them and the entire scheme collapses.