n parsing, verification, and validation. The following implementation demonstrates a production-grade TypeScript approach that enforces these boundaries.
Architecture Decisions
- Segment Isolation: The token is split into three distinct parts. Each part is processed independently to prevent cross-contamination of parsing logic.
- Base64url Normalization: Standard Base64 uses
+ and /, while JWTs use - and _. The decoder must normalize padding and character sets before conversion.
- Cryptographic Boundary: Verification never occurs in the same execution context as decoding. The verification function accepts only the raw token and a key provider, returning a verified claims object or throwing.
- Claim Validation Middleware: After verification, claims are checked against business rules (expiration, audience, issuer, custom scopes). This step is decoupled from cryptography to allow policy updates without key rotation.
Implementation
// jwt-pipeline.ts
import { createHmac, createVerify } from 'crypto';
interface JwtSegment {
header: Record<string, unknown>;
payload: Record<string, unknown>;
signature: Buffer;
}
interface KeyProvider {
getSecret(): string | Buffer;
getPublicKey(): string | Buffer;
getAlgorithm(): 'HS256' | 'RS256' | 'ES256';
}
class JwtPipeline {
private static normalizeBase64Url(input: string): string {
let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4;
if (padding) {
normalized += '='.repeat(4 - padding);
}
return normalized;
}
private static decodeSegment(segment: string): Record<string, unknown> {
const normalized = this.normalizeBase64Url(segment);
const buffer = Buffer.from(normalized, 'base64');
return JSON.parse(buffer.toString('utf-8'));
}
public static parseRawToken(rawToken: string): JwtSegment {
const parts = rawToken.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT structure: expected three dot-separated segments');
}
return {
header: this.decodeSegment(parts[0]),
payload: this.decodeSegment(parts[1]),
signature: Buffer.from(this.normalizeBase64Url(parts[2]), 'base64'),
};
}
public static verifyIntegrity(rawToken: string, keyProvider: KeyProvider): JwtSegment {
const [headerB64, payloadB64, signatureB64] = rawToken.split('.');
const signingInput = `${headerB64}.${payloadB64}`;
const algorithm = keyProvider.getAlgorithm();
const expectedSignature = this.computeSignature(signingInput, algorithm, keyProvider);
if (!this.constantTimeCompare(expectedSignature, this.normalizeBase64Url(signatureB64))) {
throw new Error('Signature verification failed: token has been tampered with');
}
return this.parseRawToken(rawToken);
}
private static computeSignature(input: string, algorithm: string, keyProvider: KeyProvider): string {
if (algorithm.startsWith('HS')) {
return createHmac(algorithm, keyProvider.getSecret())
.update(input)
.digest('base64url');
}
const verifier = createVerify(algorithm);
verifier.update(input);
return verifier.verify(keyProvider.getPublicKey(), '', 'base64url');
}
private static constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a, 'base64url');
const bufB = Buffer.from(b, 'base64url');
let diff = 0;
for (let i = 0; i < bufA.length; i++) {
diff |= bufA[i] ^ bufB[i];
}
return diff === 0;
}
}
export { JwtPipeline, KeyProvider };
Rationale Behind Design Choices
normalizeBase64Url: JWTs strip padding for URL safety. Reconstructing it prevents Buffer.from from throwing or truncating data. This is a frequent source of silent parsing failures in production.
constantTimeCompare: Standard string comparison short-circuits on the first mismatched byte, enabling timing attacks. The bitwise XOR loop ensures comparison duration remains constant regardless of input.
- Algorithm Routing: The
computeSignature method branches based on prefix (HS vs RS/ES). This prevents algorithm confusion attacks where an attacker swaps HS256 for RS256 and signs with the public key.
- Separation of
parseRawToken and verifyIntegrity: Decoding is exposed for debugging only. Verification is the gatekeeper. This enforces the trust boundary at the type level.
Pitfall Guide
1. Treating Base64url as Encryption
Explanation: Base64url is an encoding scheme, not a cipher. It transforms binary data into ASCII characters for safe transmission. No cryptographic key is involved, and the process is fully reversible.
Fix: Assume all payload data is publicly readable. Never store passwords, PII, or internal identifiers that should remain confidential. Use JWE (JSON Web Encryption) if confidentiality is required.
2. Trusting Decoded Claims Without Verification
Explanation: Client-side decoding or middleware that skips signature validation accepts tokens crafted by attackers. An adversary can modify the role or sub claim, Base64url-encode it, and attach a dummy signature.
Fix: Always run verifyIntegrity before accessing payload data. Verification must occur on the server or trusted gateway using the correct key.
3. Ignoring Standard Claim Validation
Explanation: A valid signature only proves the token was signed by the issuer. It does not guarantee the token is currently usable. Expired tokens, tokens issued to wrong audiences, or tokens with invalid issuers will still pass signature checks.
Fix: Implement a validation middleware that checks exp, nbf, iss, aud, and jti. Reject tokens that fail any business rule, even if the signature is valid.
4. Algorithm Confusion (alg: none or Key Swap)
Explanation: Attackers can modify the header to {"alg":"none"} or switch from RS256 to HS256, signing the token with the public key. Libraries that blindly trust the header algorithm will accept the forged token.
Fix: Never trust the alg header from untrusted input. Maintain a whitelist of allowed algorithms on the server. Explicitly configure your verification library to reject none and enforce expected key types.
5. Hardcoding Secrets in Client Applications
Explanation: Distributing symmetric keys (HS256) to mobile or browser clients exposes them to reverse engineering. Once extracted, attackers can generate valid tokens for any user.
Fix: Use asymmetric algorithms (RS256, ES256) for distributed systems. Clients only need the public key for verification or should never verify tokens at all. Keep private keys strictly server-side.
6. Token Bloat from Excessive Claims
Explanation: JWTs are included in every HTTP request header. Large payloads increase bandwidth, slow down parsing, and may exceed header size limits in proxies or load balancers.
Fix: Store only essential identifiers (sub, exp, iss, aud, scope). Fetch additional user context from a database or cache using the sub claim. Keep tokens under 1KB when possible.
7. Static Key Rotation Neglect
Explanation: Using a single signing key indefinitely increases blast radius if compromised. It also prevents revocation of tokens issued under older keys.
Fix: Implement key rotation with overlapping validity periods. Publish new public keys via JWKS (JSON Web Key Set) endpoints. Maintain a key ID (kid) in the header to route verification to the correct key.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single monolithic service | HS256 with environment secret | Simpler key management, lower CPU overhead | Low (shared secret storage) |
| Microservices / API Gateway | RS256 or ES256 with JWKS | Decentralized verification without secret distribution | Medium (key infrastructure) |
| Public client apps (SPA/Mobile) | RS256/ES256 + short-lived tokens | Prevents secret leakage, limits token reuse window | Low (client only verifies public key) |
| High-throughput internal APIs | HS256 with key rotation | Faster HMAC computation, acceptable if network is trusted | Low (rotation automation required) |
| Regulatory compliance (PII) | JWE + JWT or opaque tokens | Ensures payload confidentiality, meets data protection standards | High (encryption overhead + key management) |
Configuration Template
// auth-config.ts
import { JwtPipeline, KeyProvider } from './jwt-pipeline';
class ProductionKeyProvider implements KeyProvider {
private readonly secret: string;
private readonly publicKey: string;
private readonly algorithm: 'HS256' | 'RS256' | 'ES256';
constructor(env: Record<string, string | undefined>) {
this.algorithm = (env.JWT_ALGORITHM as 'HS256' | 'RS256' | 'ES256') || 'RS256';
this.secret = env.JWT_HS_SECRET || '';
this.publicKey = env.JWT_PUBLIC_KEY || '';
}
getSecret(): string {
if (!this.secret) throw new Error('HS256 secret not configured');
return this.secret;
}
getPublicKey(): string {
if (!this.publicKey) throw new Error('Public key not configured');
return this.publicKey;
}
getAlgorithm(): 'HS256' | 'RS256' | 'ES256' {
return this.algorithm;
}
}
export function createJwtVerifier(env: Record<string, string | undefined>) {
const provider = new ProductionKeyProvider(env);
return {
verify: (rawToken: string) => JwtPipeline.verifyIntegrity(rawToken, provider),
allowedAlgorithms: [provider.getAlgorithm()],
requireClaims: ['sub', 'exp', 'iss'],
};
}
Quick Start Guide
- Install dependencies: Add
crypto (Node.js built-in) or a browser-compatible WebCrypto polyfill. No external JWT libraries required for this pipeline.
- Configure environment variables: Set
JWT_ALGORITHM, JWT_HS_SECRET (if HS256), and JWT_PUBLIC_KEY (if RS256/ES256). Ensure keys are loaded at startup, not per-request.
- Initialize the verifier: Call
createJwtVerifier(process.env) during application bootstrap. Store the returned verifier in your dependency injection container or middleware chain.
- Attach to request pipeline: In your HTTP middleware, extract the
Authorization: Bearer <token> header. Pass it to verifier.verify(). Catch verification errors and return 401 Unauthorized.
- Validate claims: After successful verification, check
payload.exp against current time, verify payload.iss matches your issuer, and confirm payload.sub exists. Proceed to business logic only if all checks pass.
This pipeline enforces cryptographic boundaries, prevents common token manipulation vectors, and scales across distributed architectures without exposing signing material. Treat the JWT as a signed contract, not a data store, and your authentication layer will remain resilient against both accidental misconfiguration and active exploitation.