tly follows the header.payload.signature format. The decoder must validate structure before attempting parsing.
interface DecodedClaims {
sub?: string;
exp?: number;
iat?: number;
[key: string]: unknown;
}
class AuthTokenReader {
private static readonly TOKEN_PARTS = 3;
private static readonly DELIMITER = '.';
public static extractClaims(rawToken: string): DecodedClaims {
if (!rawToken || typeof rawToken !== 'string') {
throw new TypeError('Token must be a non-empty string');
}
const segments = rawToken.split(AuthTokenReader.DELIMITER);
if (segments.length !== AuthTokenReader.TOKEN_PARTS) {
throw new Error('Malformed token: Expected exactly three period-delimited segments');
}
return AuthTokenReader.decodeSegment(segments[1]);
}
}
Architecture Rationale: Splitting by . isolates the payload without regex overhead. Validating segment count upfront prevents silent failures and provides clear error boundaries for downstream error handling.
Step 2: Base64URL Normalization
JWTs use Base64URL encoding, which substitutes + and / with - and _, and strips trailing = padding. The atob() API expects standard Base64, so normalization is mandatory.
private static normalizeBase64Url(encoded: string): string {
let standardBase64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
const remainder = standardBase64.length % 4;
if (remainder !== 0) {
standardBase64 += '='.repeat(4 - remainder);
}
return standardBase64;
}
Architecture Rationale: The modulo-4 padding calculation is mathematically deterministic. Appending = characters restores compliance with RFC 4648 Base64 standards, ensuring atob() executes without range errors.
Step 3: UTF-8 Safe Binary Conversion
The native atob() function decodes to a Latin-1 binary string. Multi-byte UTF-8 characters (emojis, accented scripts, CJK) trigger DOMException errors. The solution maps each byte to a percent-encoded hex sequence, then delegates reconstruction to decodeURIComponent().
private static decodeSegment(payload: string): DecodedClaims {
const normalized = AuthTokenReader.normalizeBase64Url(payload);
const binaryString = atob(normalized);
const percentEncoded = binaryString
.split('')
.map(byte => `%${byte.charCodeAt(0).toString(16).padStart(2, '0')}`)
.join('');
const jsonString = decodeURIComponent(percentEncoded);
return JSON.parse(jsonString) as DecodedClaims;
}
}
Architecture Rationale: This two-stage decoding bypasses atob()'s single-byte limitation. decodeURIComponent() natively handles UTF-8 byte sequences, guaranteeing accurate reconstruction of internationalized claims. The approach adds ~0.2KB to the bundle but eliminates external dependencies entirely.
Step 4: Client-Side Expiry Validation
Frontend routing frequently requires pre-emptive token validation to prevent unnecessary API calls. The exp claim stores expiration as a Unix timestamp in seconds.
class TokenLifecycle {
public static isExpired(rawToken: string, leewaySeconds: number = 0): boolean {
try {
const claims = AuthTokenReader.extractClaims(rawToken);
if (!claims.exp) return false;
const now = Math.floor(Date.now() / 1000);
return now >= (claims.exp + leewaySeconds);
} catch {
return true;
}
}
}
Architecture Rationale: Wrapping extraction in a try/catch ensures corrupt tokens are treated as expired, failing safely. The leewaySeconds parameter accommodates clock skew between client and server, preventing premature session termination.
Pitfall Guide
1. Trusting Decoded Claims for Authorization
Explanation: Decoding only unpacks Base64URL data. It performs zero cryptographic validation. An attacker can modify the payload, re-encode it, and submit a forged token.
Fix: Never route, render, or authorize based on client-decoded claims. Use decoding strictly for UI state or pre-flight checks. Delegate all authorization decisions to the backend.
2. Ignoring Base64URL Padding Requirements
Explanation: atob() throws a DOMException when the input length is not a multiple of 4. JWTs intentionally omit padding to reduce payload size.
Fix: Always calculate length % 4 and append the corresponding number of = characters before decoding.
3. UTF-8 Corruption in Binary Strings
Explanation: atob() returns a string where each character represents a single byte. Multi-byte UTF-8 sequences split across characters, causing JSON.parse() to fail or return garbled text.
Fix: Use the percent-encoding workaround (%XX mapping) or switch to TextDecoder with a Uint8Array conversion. The percent-encoding method remains the most compatible across legacy and modern browsers.
4. LocalStorage Token Exposure
Explanation: Storing JWTs in localStorage or sessionStorage exposes them to any executed JavaScript. Cross-Site Scripting (XSS) vulnerabilities can exfiltrate tokens instantly.
Fix: Transmit tokens via Set-Cookie headers with HttpOnly, Secure, and SameSite=Strict flags. The browser manages storage automatically, and client-side scripts cannot access the value.
5. Hard Expiry Boundaries Without Leeway
Explanation: Client and server clocks rarely synchronize perfectly. A token expiring at 1700000000 may appear expired on the client 2β3 seconds before the server recognizes it, causing race conditions.
Fix: Implement a configurable leeway buffer (typically 30β60 seconds) during client-side expiry checks. Refresh tokens proactively before the hard deadline.
6. Assuming Mandatory exp Claims
Explanation: Not all JWTs include an expiration claim. Long-lived refresh tokens or internal service tokens may omit exp entirely.
Fix: Check for claim existence before comparison. Default to false (not expired) when exp is absent, but enforce server-side rotation policies for tokens without explicit lifetimes.
7. Client-Side Signature Verification
Explanation: Attempting to verify HMAC or RSA signatures in the browser requires embedding secrets or public keys in client code. Attackers can extract these keys, forge valid signatures, and bypass authentication.
Fix: Remove all verification logic from frontend bundles. Use native decoding exclusively for claim extraction. Rely on backend middleware for cryptographic validation.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Read user ID for UI greeting | Native JS Decoder | Zero dependencies, instant parsing, no security risk | -$15β30KB bundle |
| Pre-flight API call validation | Native Decoder + Expiry Leeway | Prevents 401 errors, reduces network overhead | Minimal CPU cost |
| Server-side route protection | Backend Verification Library | Cryptographic integrity, key isolation, audit compliance | Server compute cost |
| Mobile/Edge deployment | Native JS Decoder | Critical for bandwidth-constrained environments | Reduced TTI, lower egress |
| Long-lived refresh tokens | Native Decoder + Server Rotation | Client reads structure; server enforces lifecycle | Architecture complexity |
Configuration Template
// auth/token-reader.ts
export interface TokenClaims {
sub?: string;
exp?: number;
iat?: number;
roles?: string[];
[key: string]: unknown;
}
export class TokenReader {
static decode(raw: string): TokenClaims {
const [header, payload, signature] = raw.split('.');
if (!header || !payload || !signature) {
throw new Error('Invalid JWT structure');
}
return this.parsePayload(payload);
}
private static parsePayload(b64url: string): TokenClaims {
const normalized = b64url.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const utf8 = binary.split('').map(c => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`).join('');
return JSON.parse(decodeURIComponent(utf8)) as TokenClaims;
}
static isExpired(raw: string, leeway: number = 30): boolean {
try {
const { exp } = this.decode(raw);
return exp ? Math.floor(Date.now() / 1000) >= (exp + leeway) : false;
} catch {
return true;
}
}
}
Quick Start Guide
- Copy the template into your frontend utilities directory (e.g.,
src/utils/auth/token-reader.ts).
- Import and extract claims where needed:
const user = TokenReader.decode(token); console.log(user.sub);
- Add expiry checking before API calls:
if (TokenReader.isExpired(token)) { await refreshToken(); }
- Remove JWT libraries from
package.json and run your bundler to verify the dependency is eliminated.
- Audit storage strategy: Replace
localStorage.setItem('token', ...) with secure cookie handling on your authentication endpoint.
This approach delivers immediate bundle reduction, eliminates client-side cryptographic risks, and establishes a clear separation between token reading and token validation. Native parsing is not a shortcut; it is the architecturally correct pattern for frontend claim extraction.