JWT Testing: Validate, Decode, and Mock JWTs in Tests

JWT Testing: Validate, Decode, and Mock JWTs in Tests

JWT testing requires testing your token validation logic — not just the happy path (valid token accepted), but also expiry, wrong algorithm, tampered signatures, missing claims, and algorithm confusion attacks. This guide covers creating test JWTs with PyJWT and jsonwebtoken, testing validation middleware, mocking JWT verification for unit tests, and the security edge cases every JWT validator should handle.

Key Takeaways

Test JWT validation, not JWT creation. Your application verifies JWTs from external sources (Auth0, Cognito, your auth service). The thing to test is your verification code — does it correctly accept valid tokens and reject invalid ones?

Test the algorithm confusion attack. If your server accepts tokens signed with alg: none or accepts an RS256-signed token with the public key as an HS256 secret, you have a critical vulnerability. Test that both attacks are rejected.

Test all rejection cases. Wrong issuer, wrong audience, expired token, future nbf, missing required claims — all should return 401, not 500.

Use parameterized tests for JWT validation. A table-driven test with 10-15 invalid token scenarios is more readable and maintainable than 15 separate test functions.

Mock JWKS fetching in unit tests. If your validator fetches the JWKS from a remote URL, mock that HTTP call in unit tests. Testing the JWT validation logic is separate from testing that your app can reach the JWKS endpoint.

JWT Structure Refresher

A JWT has three base64url-encoded parts separated by dots:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9  (header)
.eyJzdWIiOiJ1c2VyMTIzIiwiZXhwIjoxNzE2MDAwMDAwfQ  (payload)
.SIGNATURE

The header specifies the algorithm. The payload contains claims. The signature is computed over base64url(header).base64url(payload) using the signing key.

Your application validates:

  1. Signature — computed with the correct key
  2. Algorithm — matches what you expect (RS256, HS256)
  3. Expiry (exp) — not in the past
  4. Issuer (iss) — matches your identity provider
  5. Audience (aud) — includes your API identifier
  6. Custom claims — role, tenant, permissions

Creating Test JWTs

Python (PyJWT)

pip install PyJWT cryptography
import jwt
import time
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend


def create_rsa_key():
    """Generate a test RSA key pair."""
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048,
        backend=default_backend(),
    )
    return private_key, private_key.public_key()


def make_jwt(private_key, claims: dict, algorithm="RS256") -> str:
    """Create a JWT with the given claims."""
    return jwt.encode(claims, private_key, algorithm=algorithm)


# In tests
private_key, public_key = create_rsa_key()

valid_claims = {
    "sub": "user123",
    "iss": "https://auth.example.com/",
    "aud": "https://api.example.com",
    "iat": int(time.time()),
    "exp": int(time.time()) + 3600,
    "email": "user@example.com",
    "roles": ["user"],
}

token = make_jwt(private_key, valid_claims)

Node.js (jsonwebtoken)

const jwt = require('jsonwebtoken');
const { generateKeyPairSync } = require('crypto');

function generateRSAKeys() {
  return generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: { type: 'spki', format: 'pem' },
    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
  });
}

const { privateKey, publicKey } = generateRSAKeys();

function makeJWT(claims, options = {}) {
  return jwt.sign(claims, privateKey, {
    algorithm: 'RS256',
    ...options,
  });
}

// Valid token for tests
const validToken = makeJWT({
  sub: 'user123',
  email: 'user@example.com',
  roles: ['user'],
}, {
  issuer: 'https://auth.example.com/',
  audience: 'https://api.example.com',
  expiresIn: '1h',
});

Testing JWT Validation: Parameterized Tests

Use parameterized tests to cover all validation scenarios in one block:

# test_jwt_validation.py
import pytest
import time


@pytest.fixture(scope="module")
def key_pair():
    return create_rsa_key()


@pytest.fixture(scope="module")
def valid_token(key_pair):
    private_key, _ = key_pair
    return make_jwt(private_key, {
        "sub": "user123",
        "iss": "https://auth.example.com/",
        "aud": "https://api.example.com",
        "iat": int(time.time()),
        "exp": int(time.time()) + 3600,
    })


INVALID_TOKEN_CASES = [
    pytest.param(
        "expired",
        lambda pk: make_jwt(pk, {
            "sub": "user123",
            "iss": "https://auth.example.com/",
            "aud": "https://api.example.com",
            "iat": int(time.time()) - 7200,
            "exp": int(time.time()) - 3600,  # Expired
        }),
        id="expired_token",
    ),
    pytest.param(
        "wrong_issuer",
        lambda pk: make_jwt(pk, {
            "sub": "user123",
            "iss": "https://evil.com/",  # Wrong issuer
            "aud": "https://api.example.com",
            "iat": int(time.time()),
            "exp": int(time.time()) + 3600,
        }),
        id="wrong_issuer",
    ),
    pytest.param(
        "wrong_audience",
        lambda pk: make_jwt(pk, {
            "sub": "user123",
            "iss": "https://auth.example.com/",
            "aud": "https://different-api.com",  # Wrong audience
            "iat": int(time.time()),
            "exp": int(time.time()) + 3600,
        }),
        id="wrong_audience",
    ),
    pytest.param(
        "not_yet_valid",
        lambda pk: make_jwt(pk, {
            "sub": "user123",
            "iss": "https://auth.example.com/",
            "aud": "https://api.example.com",
            "iat": int(time.time()),
            "nbf": int(time.time()) + 3600,  # Not valid for another hour
            "exp": int(time.time()) + 7200,
        }),
        id="not_yet_valid",
    ),
]


@pytest.mark.parametrize("_name, token_factory", INVALID_TOKEN_CASES)
def test_invalid_tokens_rejected(client, key_pair, _name, token_factory):
    private_key, public_key = key_pair
    token = token_factory(private_key)
    
    with patch("your_app.auth.get_public_key", return_value=public_key):
        response = client.get(
            "/api/protected",
            headers={"Authorization": f"Bearer {token}"},
        )
    
    assert response.status_code == 401


def test_valid_token_accepted(client, key_pair, valid_token):
    _, public_key = key_pair
    
    with patch("your_app.auth.get_public_key", return_value=public_key):
        response = client.get(
            "/api/protected",
            headers={"Authorization": f"Bearer {valid_token}"},
        )
    
    assert response.status_code == 200

Security Tests: Algorithm Confusion Attacks

These are critical security tests that many teams skip:

Test 1: alg: none must be rejected

def test_alg_none_token_rejected(client):
    """Tokens with alg:none must ALWAYS be rejected."""
    # Create a token with no signature
    header = base64.urlsafe_b64encode(
        json.dumps({"alg": "none", "typ": "JWT"}).encode()
    ).rstrip(b'=').decode()
    
    payload = base64.urlsafe_b64encode(
        json.dumps({
            "sub": "attacker",
            "iss": "https://auth.example.com/",
            "aud": "https://api.example.com",
            "exp": int(time.time()) + 3600,
        }).encode()
    ).rstrip(b'=').decode()
    
    none_token = f"{header}.{payload}."
    
    response = client.get(
        "/api/protected",
        headers={"Authorization": f"Bearer {none_token}"},
    )
    
    assert response.status_code == 401, "alg:none tokens must be rejected!"

Test 2: RS256 public key used as HS256 secret (key confusion)

def test_hs256_with_public_key_rejected(client, key_pair):
    """RS256 public key must NOT be accepted as an HS256 secret."""
    _, public_key = key_pair
    
    # Export public key as PEM
    pub_pem = public_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    )
    
    # Sign a token with the public key as HS256 secret
    confused_token = jwt.encode(
        {
            "sub": "attacker",
            "iss": "https://auth.example.com/",
            "aud": "https://api.example.com",
            "exp": int(time.time()) + 3600,
        },
        pub_pem,
        algorithm="HS256",
    )
    
    response = client.get(
        "/api/protected",
        headers={"Authorization": f"Bearer {confused_token}"},
    )
    
    # If your server only accepts RS256, this will be rejected
    assert response.status_code == 401, "HS256 tokens must be rejected when expecting RS256!"

Test 3: Tampered signature rejected

def test_tampered_signature_rejected(client, valid_token):
    """Modifying the signature must invalidate the token."""
    parts = valid_token.split(".")
    # Corrupt the last byte of the signature
    tampered_sig = parts[2][:-1] + ("A" if parts[2][-1] != "A" else "B")
    tampered_token = f"{parts[0]}.{parts[1]}.{tampered_sig}"
    
    response = client.get(
        "/api/protected",
        headers={"Authorization": f"Bearer {tampered_token}"},
    )
    assert response.status_code == 401

Testing JWT Claims Extraction

Your handlers probably extract claims to determine the user's identity and permissions:

def test_user_id_extracted_from_sub_claim(client, key_pair):
    private_key, public_key = key_pair
    user_id = "user_abc123"
    
    token = make_jwt(private_key, {
        "sub": user_id,
        "iss": "https://auth.example.com/",
        "aud": "https://api.example.com",
        "iat": int(time.time()),
        "exp": int(time.time()) + 3600,
    })
    
    with patch("your_app.auth.get_public_key", return_value=public_key):
        response = client.get(
            "/api/profile",
            headers={"Authorization": f"Bearer {token}"},
        )
    
    assert response.status_code == 200
    assert response.json()["id"] == user_id


def test_roles_from_custom_claim(client, key_pair):
    private_key, public_key = key_pair
    
    token = make_jwt(private_key, {
        "sub": "user123",
        "iss": "https://auth.example.com/",
        "aud": "https://api.example.com",
        "iat": int(time.time()),
        "exp": int(time.time()) + 3600,
        "https://your-app.com/roles": ["admin"],  # Custom namespace claim
    })
    
    with patch("your_app.auth.get_public_key", return_value=public_key):
        response = client.get(
            "/api/admin/dashboard",
            headers={"Authorization": f"Bearer {token}"},
        )
    
    assert response.status_code == 200

Decoding JWTs in Tests (Without Verification)

Sometimes you need to inspect a JWT your application issued to verify it contains the right claims:

def test_issued_token_contains_correct_claims():
    response = client.post("/api/auth/login", json={
        "email": "user@example.com",
        "password": "correct_password",
    })
    assert response.status_code == 200
    
    token = response.json()["access_token"]
    
    # Decode without verification to inspect claims
    payload = jwt.decode(token, options={"verify_signature": False})
    
    assert payload["sub"] is not None
    assert payload["email"] == "user@example.com"
    assert "exp" in payload
    assert payload["exp"] > time.time()
    assert "iss" in payload
    assert "aud" in payload

Mocking JWT Verification for Unit Tests

When unit testing a service that requires an authenticated user, inject the decoded claims directly:

# If your middleware sets request.user = decoded_claims after verification
from unittest.mock import patch


def test_service_with_mocked_auth():
    mock_user = {
        "sub": "test-user-123",
        "email": "test@example.com",
        "roles": ["user"],
    }
    
    with patch("your_app.middleware.verify_jwt", return_value=mock_user):
        response = client.get(
            "/api/profile",
            headers={"Authorization": "Bearer any_value"},
        )
    
    assert response.status_code == 200

JWT Testing Checklist

Test Expected result
Valid RS256 token 200
Expired token 401
Wrong issuer 401
Wrong audience 401
nbf in the future 401
Missing exp 401
alg: none 401
HS256 with public key 401
Tampered signature 401
Missing Authorization header 401
Bearer without token 401
Custom claim extraction Claims available in handler
Role-based access 200 for correct role, 403 for wrong

Summary

JWT testing is primarily about testing your validation middleware, not the JWT standard itself. Write parameterized tests for all rejection cases. Test the alg: none and key confusion attacks — these are well-known vulnerabilities that still appear in production. Use PyJWT or jsonwebtoken to create test tokens, mock JWKS fetching for unit tests, and decode tokens without verification when inspecting their contents.

Read more