JWT Anatomy and Security — Token Structure, Signature Algorithms, and Common Vulnerabilities

A JSON Web Token (JWT, defined in RFC 7519) is a compact, URL-safe token format for passing claims between parties. You've seen them in OAuth/OIDC flows, API authentication, and session management. A JWT looks like xxxxx.yyyyy.zzzzz — three Base64url segments separated by dots. But the simplicity of the format hides a long history of security pitfalls that have led to serious production breaches. This guide covers the structure, signature algorithms, and the attacks you must defend against.

Decode a real token: Paste any JWT into the JWT Decoder & Debugger to inspect the header, payload, and signature — it decodes locally in your browser.

1. The Three-Part Structure

A JWT is header.payload.signature, each part Base64url-encoded:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9       ← header
.eyJzdWIiOiJhbGljZSIsImlhdCI6MTcyMzMyMjQwMH0  ← payload
.d0v2mPz8bK_LjGq7n6QhW3vK8nR2sT5mN9pV1xYz0aM  ← signature (HS256)

Header

The header declares the token type and signing algorithm:

{
  "alg": "HS256",        // signing algorithm
  "typ": "JWT",          // token type
  "kid": "key-2026-01"   // optional: key ID for rotation
}

Payload (Claims)

The payload contains claims — statements about the subject and additional data. There are three types:

ClaimTypeMeaning
issRegisteredIssuer — who created the token
subRegisteredSubject — who the token is about (user ID)
audRegisteredAudience — intended recipient
expRegisteredExpiration time (Unix timestamp)
iatRegisteredIssued at (Unix timestamp)
nbfRegisteredNot before — token valid after this time
jtiRegisteredJWT ID — unique identifier for the token
roleCustomApplication-specific: user role
scopeCustomOAuth scopes granted
The payload is not encrypted. In a standard JWT (JWS), the payload is Base64url-encoded, not encrypted. Anyone who intercepts the token can read all claims by Base64-decoding. Never put sensitive data (passwords, SSNs, API keys) in a JWT payload. If you need confidentiality, use JWE (JSON Web Encryption) instead.

2. Signature Algorithms: HS256 vs RS256 vs ES256

The signature proves that the token hasn't been tampered with. The algorithm is declared in the alg header field:

AlgorithmTypeKey UsedVerificationWhen to Use
HS256HMAC-SHA256Shared secretSame secretSingle-server, simple apps
RS256RSA-PKCS1-v1.5Private key (2048+ bit)Public keyMulti-party, OIDC
ES256ECDSA P-256Private keyPublic keyModern, mobile, IoT
PS256RSA-PSSPrivate keyPublic keyHigh-security RSA
noneNo signatureNoneNoneNever use

HS256: HMAC-SHA256

The simplest algorithm — it uses HMAC-SHA256 with a shared secret. The signature is HMAC-SHA256(secret, base64url(header) + "." + base64url(payload)).

# Python: create and verify HS256 JWT
import jwt  # PyJWT

secret = "your-256-bit-secret"

# Create token
token = jwt.encode(
    {"sub": "alice", "exp": 1723360800, "iat": 1723322400},
    secret, algorithm="HS256"
)

# Verify token — will raise ExpiredSignatureError if expired
claims = jwt.decode(token, secret, algorithms=["HS256"], audience="myapp")
# {"sub": "alice", "exp": 1723360800, "iat": 1723322400}
HMAC uses a shared secret: Both the issuer and verifier need the same secret. This is fine for a single-server app, but if you need to let third parties verify tokens (without giving them the ability to sign new ones), use RS256 instead. See the HMAC Guide for HMAC internals.

RS256: RSA Signature

RS256 uses RSA with PKCS#1 v1.5 padding. The private key signs; the public key verifies. This allows anyone with the public key to verify tokens, but only the private key holder can create them:

# Python: RS256 with public/private key pair
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

# Generate key pair (do once, store securely)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()

# Serialize for storage
priv_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption()
)
pub_pem = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)

# Sign JWT
token = jwt.encode(payload, priv_pem, algorithm="RS256")

# Verify with public key only
claims = jwt.decode(token, pub_pem, algorithms=["RS256"], audience="myapp")
RS256 token size: An RS256 JWT is typically 800-1000+ bytes because the RSA signature alone is 256 bytes (2048-bit key). An HS256 JWT with the same payload might be only 200 bytes. Use ES256 if token size matters (e.g., mobile, IoT).

ES256: ECDSA with P-256

ES256 uses Elliptic Curve Digital Signature Algorithm (ECDSA) over the NIST P-256 curve. It produces compact 64-byte signatures while providing equivalent security to a 3072-bit RSA key:

AlgorithmKey SizeSignature SizeEquivalent Symmetric Security
HS256256 bits (shared secret)32 bytes128 bits
RS2562048 bits256 bytes112 bits
ES256256 bits64 bytes128 bits
RS3843072 bits384 bytes128 bits
ES384384 bits96 bytes192 bits

3. Signature Verification: The Right Way

The most common JWT vulnerability comes from incorrect verification. Here's the safe pattern:

# Python: secure JWT verification
import jwt

def verify_token(token, secret):
    try:
        claims = jwt.decode(
            token,
            secret,
            algorithms=["HS256"],      # MUST specify expected algorithm
            audience="myapp",         # MUST check audience
            issuer="https://auth.example.com",  # MUST check issuer
            options={
                "require": ["exp", "iat", "sub"],  # MUST require critical claims
            }
        )
        return claims
    except jwt.ExpiredSignatureError:
        raise Exception("Token expired")
    except jwt.InvalidAudienceError:
        raise Exception("Wrong audience")
    except jwt.InvalidIssuerError:
        raise Exception("Wrong issuer")
    except jwt.InvalidTokenError:
        raise Exception("Invalid token")
Always pass algorithms=[...] explicitly: Many libraries will default to accepting whatever algorithm is in the token header if you don't specify. This is the root cause of the alg:none and algorithm confusion attacks below. Pin the algorithm your application uses — never trust the token's self-declared algorithm.

4. Security Vulnerabilities

4.1 The alg:none Attack

RFC 7519 defines an algorithm called none — no signature. Some JWT libraries, when the alg header says none, skip signature verification entirely. An attacker can forge a token:

// Attacker forges a token with alg:none
const header = Buffer.from('{"alg":"none","typ":"JWT"}').toString('base64url');
const payload = Buffer.from('{"sub":"admin","role":"superuser"}').toString('base64url');
const forgedToken = header + "." + payload + ".";  // empty signature

If the server uses a library that doesn't explicitly check the algorithm, it accepts this token as valid with no signature at all.

Mitigation: Always pass algorithms=["HS256"] (or whichever you use) to the verification function. If none is not in your allowed list, the library will reject it. Never use a library that defaults to accepting none.

4.2 Algorithm Confusion Attack (RS256 → HS256)

If your server accepts tokens signed with RS256 (asymmetric) and the attacker can trick it into using HS256 (symmetric) with the public key as the HMAC secret, they can forge tokens:

  1. Server expects RS256, has the RSA public key for verification.
  2. Attacker sends a token with alg: HS256 and signs it using HMAC-SHA256 with the public key as the secret.
  3. If the server's library uses the alg header to choose the verification method, it tries HMAC-SHA256 verification using the public key — which the attacker also knows.
  4. Signature matches — forged token accepted.
Mitigation: Pin the algorithm. If you use RS256, verify with algorithms=["RS256"] only. Never let the token tell you which algorithm to use. Additionally, don't treat RSA public keys as HMAC secrets — this is a library design flaw, but pinning the algorithm defends against it.

4.3 Key ID (kid) Injection

The kid header parameter helps the server pick the right key for verification. If the server uses kid to construct a file path or database query, it can be vulnerable to path traversal or SQL injection:

// Malicious header with path traversal
{"alg":"HS256","kid":"../../dev/null"}

If the server loads the verification key from /keys/{kid}.pem, this reads /dev/null — an empty file. The attacker can then sign tokens with an empty HMAC key.

Mitigation: Treat kid as untrusted input. Validate it against a whitelist of allowed key IDs, don't use it in file paths or SQL queries, and use a constant string mapping if possible.

5. Token Storage and Lifecycle

Where to Store JWTs on the Client

StorageXSS SafeCSRF SafeRecommended
localStorageNo (JS can read)YesOnly for non-sensitive tokens
sessionStorageNoYesSame as localStorage, cleared on tab close
HttpOnly CookieYes (JS can't read)No (needs CSRF token)Recommended for web apps
Memory (variable)YesYesSPAs with silent refresh
Best practice for web apps: Store the access token in memory (JavaScript variable) and the refresh token in an HttpOnly, Secure, SameSite=Strict cookie. On page load, use the refresh token to get a new access token silently. This is the approach used by libraries like oidc-client-ts and Auth0's SDK.

Access Token vs Refresh Token

PropertyAccess TokenRefresh Token
Lifetime5-30 minutes7-30 days
PurposeAuthenticate API requestsGet new access tokens
StorageMemory / localStorageHttpOnly cookie / server-side
Sent toEvery API request (Authorization header)Token endpoint only
RevocationWait for expirationImmediate (server-side revocation list)
Keep access tokens short-lived. A 5-minute access token limits the damage of theft to a 5-minute window. If stolen, the attacker can make API calls until the token expires, but the refresh token (stored more securely) is harder to steal. Never set exp to hours or days for access tokens.

6. JavaScript: Decode and Verify

// Browser: decode JWT (no verification — for debugging only)
function decodeJWT(token) {
  const [headerB64, payloadB64, signatureB64] = token.split(".");
  const decode = (s) => JSON.parse(atob(s.replace(/-/g, "+").replace(/_/g, "/")));

  return {
    header: decode(headerB64),
    payload: decode(payloadB64),
    signature: signatureB64,
    // Signature is binary — show as hex
    signatureHex: atob(signatureB64.replace(/-/g, "+").replace(/_/g, "/"))
      .split("").map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join("")
  };
}

// Browser: verify HS256 using Web Crypto API
async function verifyHS256(token, secret) {
  const [headerB64, payloadB64, signatureB64] = token.split(".");
  const data = new TextEncoder().encode(headerB64 + "." + payloadB64);
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["verify"]
  );
  const signature = Uint8Array.from(
    atob(signatureB64.replace(/-/g, "+").replace(/_/g, "/")),
    c => c.charCodeAt(0)
  );
  return crypto.subtle.verify("HMAC", key, signature, data);
}
Verify before trusting: Decoding a JWT without verification only shows what the token claims — it doesn't prove authenticity. Always verify the signature server-side. The JWT Decoder tool decodes for debugging purposes, and clearly flags when a token has no signature or uses alg:none.

7. Common Pitfalls Checklist

PitfallRiskFix
Not pinning algorithmalg:none / algorithm confusionAlways pass algorithms=["HS256"] to verify
Putting secrets in payloadAnyone can read (Base64, not encrypted)Use JWE or keep sensitive data server-side
Long-lived access tokensStolen tokens valid for hours/days5-30 min expiry + refresh token
Not checking expExpired tokens acceptedEnable verify_exp: true (default in most libs)
Not checking aud / issCross-service token reuseAlways specify expected audience and issuer
Storing in localStorageXSS can steal tokensUse HttpOnly cookie + refresh token pattern
Using kid in file pathsPath traversal to load wrong keyWhitelist key IDs, don't interpolate into paths

8. FAQ

What is the difference between HS256 and RS256 in JWT?

HS256 uses HMAC-SHA256 with a shared secret that both the issuer and verifier must know. RS256 uses RSA with a private key for signing and a public key for verification. HS256 is simpler but requires sharing the secret. RS256 allows verification with only the public key, so third parties can verify tokens without the signing key.

Can JWT be encrypted instead of just signed?

Yes. JWT has two specifications: JWS (JSON Web Signature) which signs but does not encrypt, and JWE (JSON Web Encryption) which encrypts the token. Standard JWT tokens are JWS (the header.payload.signature format). If you need confidentiality (hiding claims from the client), use JWE instead.

What is the JWT alg:none attack?

The alg:none attack exploits JWT implementations that accept the algorithm header value 'none', which means no signature was applied. An attacker sets the header to {"alg":"none"} and sends any payload without a valid signature. If the server doesn't explicitly check the algorithm, it accepts the forged token. Always pin the expected algorithm on the server side.

How long should a JWT token live?

Access tokens should be short-lived: 5 to 30 minutes. This limits the window if a token is stolen. Use a refresh token (longer-lived, stored more securely) to obtain new access tokens without re-authentication. Never make access tokens long-lived (hours or days) as this increases the impact of theft.