Skip to main content
Cloud & AI Hub
Browse
Glossary AI Directory Playgrounds Models Prompts Explainers Strategy Matrix Benchmark Decoder
intermediate 8 mins Read

Understanding JWT Signature Verification Mechanics

How JSON Web Tokens are structured, signed, and verified — including RS256 vs HS256 algorithm security implications.

Introduction

JSON Web Tokens (JWTs) are the standard mechanism for stateless authentication in modern APIs. Instead of storing session data server-side (requiring database lookups on every request), a JWT encodes user claims directly in a signed token that clients present with each request. The server verifies the cryptographic signature to confirm the token hasn’t been tampered with — all without a database call. However, JWTs are frequently misconfigured, leading to security vulnerabilities.

JWT Structure

A JWT is three base64url-encoded JSON objects separated by dots:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDQwNjcyMDAsImV4cCI6MTcwNDA3MDgwMH0.SIGNATURE

Header.Payload.Signature

Decoded:

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

// Payload (claims)
{
  "sub": "user_123",
  "email": "[email protected]",
  "role": "admin",
  "iat": 1704067200,   // Issued At (Unix timestamp)
  "exp": 1704070800    // Expires At (1 hour later)
}

// Signature: RSA-SHA256(base64(header) + "." + base64(payload), private_key)
flowchart LR
    H["Header (alg, typ)"] --> J["Token = base64url(Header).base64url(Payload).Signature"]
    P["Payload (claims)"] --> J
    J --> V["Server recomputes the signature from Header + Payload"]
    V -->|"matches"| OK["Trusted — claims accepted"]
    V -->|"mismatch"| REJ["Rejected — 401"]

RS256 vs HS256: Security Implications

HS256 (HMAC-SHA256 — Symmetric)

import jwt, secrets

SECRET_KEY = secrets.token_hex(32)  # Same key used for signing AND verification

# Signing (server-side)
token = jwt.encode({"sub": "user_123", "role": "admin"}, SECRET_KEY, algorithm="HS256")

# Verification (server-side)
claims = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

Risk: If any service that verifies tokens is compromised, the attacker can forge tokens. All verifiers must share the secret.

RS256 (RSA-SHA256 — Asymmetric)

from cryptography.hazmat.primitives.asymmetric import rsa
import jwt

# Generate key pair (one-time setup)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()

# Signing — ONLY the auth server needs the private key
token = jwt.encode({"sub": "user_123", "role": "admin"}, private_key, algorithm="RS256")

# Verification — any service can verify with the public key (JWKS endpoint)
claims = jwt.decode(token, public_key, algorithms=["RS256"])

Advantage: Only the auth server holds the private key. APIs verify using the public key (published at JWKS endpoint) — even if an API server is compromised, an attacker cannot forge tokens.

Fetching and Verifying with JWKS

import requests
from jwt.algorithms import RSAAlgorithm
import jwt

JWKS_URL = "https://auth.example.com/.well-known/jwks.json"

def get_public_key(kid: str):
    jwks = requests.get(JWKS_URL).json()
    for key in jwks['keys']:
        if key['kid'] == kid:
            return RSAAlgorithm.from_jwk(key)
    raise ValueError(f"Key ID {kid} not found in JWKS")

def verify_token(token: str) -> dict:
    # Get key ID from header without verifying signature
    header = jwt.get_unverified_header(token)
    public_key = get_public_key(header['kid'])
    
    return jwt.decode(
        token,
        public_key,
        algorithms=['RS256'],
        audience='https://api.example.com',
        options={
            'verify_exp': True,    # Always verify expiration
            'verify_iat': True,    # Verify issued-at
            'require': ['exp', 'iat', 'sub']  # Require these claims
        }
    )

Critical Security Rules

# ❌ NEVER DO THIS — "alg: none" attack
jwt.decode(token, options={"verify_signature": False})

# ❌ NEVER DO THIS — accept any algorithm from header
jwt.decode(token, key, algorithms=["RS256", "HS256", "none"])

# ✅ ALWAYS pin the algorithm
jwt.decode(token, public_key, algorithms=["RS256"])  # Pin exactly

# ✅ ALWAYS verify expiration
jwt.decode(token, key, algorithms=["RS256"], options={"verify_exp": True})

# ✅ ALWAYS validate audience claim to prevent cross-service token reuse
jwt.decode(token, key, algorithms=["RS256"], audience="https://api.example.com")

Key Takeaways

  • JWTs consist of three base64url-encoded parts: Header (algorithm), Payload (claims), Signature (cryptographic proof).
  • RS256 (asymmetric) is preferred over HS256: only the auth server can sign, all APIs can verify with the public JWKS key.
  • The “alg: none” attack bypasses signature verification — always pin the algorithm in your verification call, never trust the token header.
  • Always verify exp (expiration) and aud (audience) claims — audience validation prevents one service’s tokens from being used at another.
  • Fetch JWKS dynamically and cache it — auth servers rotate signing keys, and hardcoded public keys will break after rotation.

Historical figures, architectures, and capabilities are for informational purposes only. Not technical, professional, legal, or financial advice. Sources: Research papers, developer documentation.