Skip to main content

JWT Token Validation

Learn how to validate JSON Web Tokens (JWT) used in OIDC authentication workflows.

Overview​

OIDC tokens are JWTs - digitally signed JSON objects that contain claims about the authenticated identity. Proper validation ensures tokens are authentic and haven't been tampered with.

warning

Security Critical: Improper token validation can lead to authentication bypass. Follow all validation steps carefully.

JWT Structure​

A JWT consists of three base64-encoded parts separated by dots:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature
│ │ │
│ │ └─ Signature
│ └───────────────────── Payload (Claims)
└──────────────────────────────────────────────────────── Header

Contains metadata about the token:

{
"alg": "RS256", // Signing algorithm (MUST be RS256 for OIDC)
"typ": "JWT", // Token type
"kid": "abc123" // Key ID (identifies which public key to use)
}

Payload (Claims)​

Contains identity information and metadata:

{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:owner/repo:ref:refs/heads/main",
"aud": "https://api.yourservice.com",
"exp": 1735689600,
"iat": 1735686000,
"nbf": 1735686000,
"jti": "unique-token-id",
// Issuer-specific claims
"repository": "owner/repo",
"ref": "refs/heads/main"
}

Signature​

Cryptographic signature created using the issuer's private key. Validates token authenticity.

Validation Steps​

Validate tokens in this exact order:

1. Decode Without Verification​

First, decode the token to extract the header and payload without verifying the signature:

const jwt = require('jsonwebtoken');

// Decode to inspect claims (does NOT verify)
const decoded = jwt.decode(token, { complete: true });

const header = decoded.header; // { alg, typ, kid }
const payload = decoded.payload; // { iss, sub, aud, exp, ... }
danger

Never trust decoded claims until signature is verified! This step is only for extracting metadata (like iss and kid) needed for verification.

2. Identify the Issuer​

Extract the issuer (iss) claim to determine which public keys to use:

const issuer = decoded.payload.iss;

// Map issuer to JWKS endpoint
const jwksEndpoints = {
'https://token.actions.githubusercontent.com': 'https://token.actions.githubusercontent.com/.well-known/jwks',
'https://gitlab.com': 'https://gitlab.com/oauth/discovery/keys',
'https://accounts.google.com': 'https://www.googleapis.com/oauth2/v3/certs'
};

const jwksUri = jwksEndpoints[issuer];
if (!jwksUri) {
throw new Error(`Unknown issuer: ${issuer}`);
}

3. Fetch Public Keys (JWKS)​

Retrieve the JSON Web Key Set (JWKS) from the issuer:

const jwksClient = require('jwks-rsa');

const client = jwksClient({
jwksUri: jwksUri,
cache: true, // Cache keys for performance
cacheMaxAge: 600000, // 10 minutes
rateLimit: true,
jwksRequestsPerMinute: 10
});

// Get the signing key using the key ID (kid) from token header
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
}

JWKS Example Response:

{
"keys": [
{
"kty": "RSA",
"kid": "abc123",
"use": "sig",
"n": "0vx7agoebGcQ...", // RSA public key modulus
"e": "AQAB" // RSA public key exponent
}
]
}

4. Verify Signature​

Use the public key to verify the token's signature:

const verifiedPayload = jwt.verify(token, getKey, {
algorithms: ['RS256'], // Only allow RS256 (REQUIRED for security)
issuer: issuer,
audience: 'https://api.yourservice.com',
clockTolerance: 60 // Allow 60 seconds clock skew
});
warning

Algorithm Confusion Attack: Always specify algorithms: ['RS256']. Never allow HS256 (HMAC) for OIDC tokens.

5. Validate Claims​

After signature verification, validate required claims (covered in Claims Verification).

JWKS Caching​

Fetching JWKS on every request is slow and can hit rate limits. Implement caching:

In-Memory Cache​

const NodeCache = require('node-cache');
const jwksCache = new NodeCache({ stdTTL: 600 }); // 10 minutes

async function getJWKS(issuer) {
const cached = jwksCache.get(issuer);
if (cached) return cached;

const response = await fetch(jwksEndpoints[issuer]);
const jwks = await response.json();

jwksCache.set(issuer, jwks);
return jwks;
}

Cache Invalidation​

Handle key rotation gracefully:

async function verifyWithRetry(token) {
try {
return await verifyToken(token);
} catch (error) {
if (error.name === 'JsonWebTokenError' && error.message.includes('invalid signature')) {
// Key might have rotated - clear cache and retry
jwksCache.flushAll();
return await verifyToken(token);
}
throw error;
}
}

Common Algorithms​

RS256 (RSA + SHA256) - REQUIRED​

Asymmetric signing using RSA public/private key pairs. This is the only algorithm allowed for OIDC.

  • Issuer signs with private key
  • You verify with public key (from JWKS)
  • Public key cannot create signatures
  • Industry standard for OIDC

HS256 (HMAC + SHA256) - NEVER USE FOR OIDC​

Symmetric signing using shared secret. Do NOT accept HS256 for OIDC tokens.

  • Both parties share the same secret
  • Anyone who can verify can also sign
  • Security risk: if you treat public key as HMAC secret, attackers can forge tokens
danger

Never allow HS256 for OIDC. Attackers can exploit algorithm confusion to forge tokens using your public key as an HMAC secret.

Security Best Practices​

1. Validate Algorithm​

// ✅ CORRECT: Only allow RS256
jwt.verify(token, getKey, { algorithms: ['RS256'] });

// ❌ WRONG: Allows algorithm confusion attacks
jwt.verify(token, getKey); // Uses algorithm from token header

2. Use Current Libraries​

Keep JWT libraries up to date:

npm outdated jsonwebtoken jwks-rsa
npm update jsonwebtoken jwks-rsa

3. Handle Errors Securely​

Don't leak information in error messages:

try {
const claims = await verifyToken(token);
} catch (error) {
// ❌ WRONG: Leaks details
res.status(401).json({ error: error.message });

// ✅ CORRECT: Generic message
res.status(401).json({ error: 'Invalid or expired token' });

// Log detailed error securely
logger.error('Token validation failed', { error: error.message, token: token.substring(0, 20) });
}

4. Rate Limit JWKS Fetches​

Prevent DoS by rate limiting JWKS endpoint requests:

const client = jwksClient({
jwksUri: jwksUri,
rateLimit: true,
jwksRequestsPerMinute: 10
});

Example: Complete Validation​

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const allowedIssuers = {
'https://token.actions.githubusercontent.com': {
jwksUri: 'https://token.actions.githubusercontent.com/.well-known/jwks',
audience: 'https://api.yourservice.com'
}
};

// Create JWKS clients for each issuer
const jwksClients = {};
Object.entries(allowedIssuers).forEach(([issuer, config]) => {
jwksClients[issuer] = jwksClient({
jwksUri: config.jwksUri,
cache: true,
cacheMaxAge: 600000,
rateLimit: true,
jwksRequestsPerMinute: 10
});
});

async function verifyToken(token) {
// Step 1: Decode without verification
const decoded = jwt.decode(token, { complete: true });
if (!decoded) {
throw new Error('Invalid token format');
}

// Step 2: Identify issuer
const issuer = decoded.payload.iss;
const issuerConfig = allowedIssuers[issuer];
if (!issuerConfig) {
throw new Error('Unknown issuer');
}

// Step 3: Get signing key
const client = jwksClients[issuer];
const getKey = (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.publicKey || key.rsaPublicKey);
});
};

// Step 4 & 5: Verify signature and validate claims
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: issuer,
audience: issuerConfig.audience,
clockTolerance: 60
}, (err, payload) => {
if (err) return reject(err);
resolve(payload);
});
});
}

module.exports = { verifyToken };

Testing Token Validation​

Unit Tests​

const { verifyToken } = require('./auth');

describe('Token Validation', () => {
it('should reject tokens with invalid signature', async () => {
const invalidToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHViLmNvbSJ9.invalid';
await expect(verifyToken(invalidToken)).rejects.toThrow();
});

it('should reject expired tokens', async () => {
const expiredToken = createExpiredToken();
await expect(verifyToken(expiredToken)).rejects.toThrow('jwt expired');
});

it('should accept valid tokens', async () => {
const validToken = await getValidTestToken();
const claims = await verifyToken(validToken);
expect(claims.iss).toBe('https://token.actions.githubusercontent.com');
});
});

Troubleshooting​

"invalid signature" Error​

Causes:

  • Token signed with different key than in JWKS
  • JWKS cache is stale (key rotation occurred)
  • Token is from wrong issuer
  • Token has been tampered with

Solutions:

  1. Clear JWKS cache and retry
  2. Verify issuer matches expected value
  3. Check token hasn't expired
  4. Ensure JWKS URL is correct

"no matching key" Error​

Causes:

  • Token's kid (key ID) not found in JWKS
  • JWKS endpoint returned empty response
  • Key rotation just occurred

Solutions:

  1. Fetch fresh JWKS from endpoint
  2. Verify JWKS endpoint is accessible
  3. Check token's kid header value
  4. Retry after clearing cache

Performance Issues​

Symptoms:

  • Slow token validation
  • High latency on authenticated requests

Solutions:

  1. Enable JWKS caching (10-60 minutes)
  2. Use connection pooling for JWKS fetches
  3. Implement health checks for JWKS endpoints
  4. Monitor cache hit rates

Next Steps​