e.
*/
export interface UserProfile extends StandardClaims {
userId: string;
tenantId: string;
roles: string[];
scope: string;
}
/**
- Refresh tokens typically contain fewer claims.
- Separating this interface prevents accidental access to user data.
*/
export interface RefreshCredential extends StandardClaims {
userId: string;
sessionId: string;
}
#### 3. Implementing a Safe Parsing Wrapper
Directly calling the library function scatters error handling and type logic. A wrapper function centralizes parsing, enforces types, and manages exceptions.
```typescript
// utils/token-parser.ts
import { jwtDecode } from 'jwt-decode';
/**
* Custom error class for token parsing failures.
* Enables granular error handling in calling code.
*/
export class TokenParseError extends Error {
constructor(message: string) {
super(message);
this.name = 'TokenParseError';
}
}
/**
* Safely decodes a JWT string into a typed object.
*
* @param token - The raw JWT string.
* @returns The decoded payload matching the generic type T.
* @throws TokenParseError if the token is malformed or invalid.
*/
export function extractClaims<T>(token: string): T {
if (!token || typeof token !== 'string') {
throw new TokenParseError('Invalid token format: expected a non-empty string.');
}
try {
// jwtDecode accepts a generic parameter to enforce type safety.
// This ensures the return value matches T at compile time.
return jwtDecode<T>(token);
} catch (error) {
// The library throws on malformed Base64 or invalid structure.
// Normalize the error for consistent handling upstream.
throw new TokenParseError(
`Failed to decode token: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
4. Expiry Validation Logic
JWT expiry checks require precise time math. Create a utility to handle the seconds-to-milliseconds conversion and allow for clock skew tolerance.
// utils/time-utils.ts
/**
* Checks if a token is expired based on its 'exp' claim.
*
* @param expiryTimestamp - The 'exp' value from the JWT payload (Unix seconds).
* @param clockSkewSeconds - Tolerance for clock differences (default: 30s).
* @returns True if the token is expired or expiring within the skew window.
*/
export function isTokenExpired(expiryTimestamp: number, clockSkewSeconds: number = 30): boolean {
// JWT 'exp' is in seconds; Date.now() is in milliseconds.
const expiryMs = expiryTimestamp * 1000;
const skewMs = clockSkewSeconds * 1000;
return Date.now() >= (expiryMs - skewMs);
}
5. Session Management Implementation
Combine parsing and validation into a session manager. This pattern abstracts token lifecycle logic from UI components.
// services/session-manager.ts
import { extractClaims, TokenParseError } from '../utils/token-parser';
import { isTokenExpired } from '../utils/time-utils';
import { UserProfile, RefreshCredential } from '../types/auth.types';
export class SessionManager {
private accessToken: string | null = null;
private refreshToken: string | null = null;
constructor(storage: Storage) {
this.accessToken = storage.getItem('auth_access_token');
this.refreshToken = storage.getItem('auth_refresh_token');
}
/**
* Retrieves the current user profile if the access token is valid.
*/
public getCurrentUser(): UserProfile | null {
if (!this.accessToken) return null;
try {
const profile = extractClaims<UserProfile>(this.accessToken);
if (isTokenExpired(profile.exp)) {
console.warn('Access token expired; refresh required.');
return null;
}
return profile;
} catch (error) {
if (error instanceof TokenParseError) {
console.error('Token corruption detected:', error.message);
this.clearSession();
}
return null;
}
}
/**
* Validates the refresh token structure without trusting its signature.
*/
public getRefreshContext(): RefreshCredential | null {
if (!this.refreshToken) return null;
try {
const context = extractClaims<RefreshCredential>(this.refreshToken);
return isTokenExpired(context.exp) ? null : context;
} catch {
this.clearSession();
return null;
}
}
public clearSession(): void {
this.accessToken = null;
this.refreshToken = null;
// Clear storage implementation omitted for brevity
}
}
Pitfall Guide
1. The "Decode is Verify" Trap
Explanation: Developers often assume that because a token decodes successfully, it is authentic. jwt-decode performs no signature verification. An attacker can modify the payload, Base64 encode it, and the decoder will parse it without error.
Fix: Never trust decoded data for authorization decisions on the server. Always use a library like jsonwebtoken to verify the signature against a secret or public key on the backend. Client-side decoding is only for reading data, not validating trust.
2. The any Type Leak
Explanation: Calling jwtDecode(token) without a generic parameter returns any. This disables TypeScript's type checking, allowing access to non-existent properties and masking structural changes in the token payload.
Fix: Always use the generic syntax jwtDecode<YourInterface>(token). If the token structure varies, use a union type or a discriminated union based on the type claim.
3. Expiry Math Mismatch
Explanation: JWT exp claims are integers representing seconds since the epoch. JavaScript Date.now() returns milliseconds. Comparing them directly (Date.now() > token.exp) results in immediate false positives, as the millisecond value is vastly larger.
Fix: Multiply the exp value by 1000 before comparison. Implement a helper function like isTokenExpired to centralize this logic and prevent repetition errors.
4. Sensitive Data Exposure
Explanation: JWT payloads are Base64 encoded, not encrypted. Anyone with the token can decode the payload and read all claims. Storing passwords, credit card numbers, or PII in the token exposes this data to the client and any intermediary.
Fix: Only include non-sensitive claims necessary for the client to function. Use opaque session tokens or reference IDs if sensitive data must be associated with the user.
5. Revocation Misconception
Explanation: JWTs are stateless by design. Once issued, a JWT cannot be revoked until it expires. Checking a "revoked list" on every request defeats the stateless benefit and introduces latency.
Fix: Use short-lived access tokens (e.g., 5-15 minutes) combined with refresh tokens. For immediate revocation, maintain a server-side blocklist of jti claims or use a version counter in the token that is validated against the user's current session version in the database.
6. Client-Side Secret Leakage
Explanation: Attempting to verify JWT signatures on the client requires exposing the signing secret or private key. This allows attackers to forge valid tokens.
Fix: Signature verification must occur exclusively on the server. The client should only decode tokens for UI rendering or routing decisions based on claims that are validated server-side.
7. Missing Standard Claims
Explanation: Custom tokens often omit standard claims like iat or exp, making it difficult to implement generic validation logic or audit token age.
Fix: Enforce a schema that includes iat and exp for all tokens. Use iat to detect replay attacks or tokens issued before a password change.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Client-side UI Rendering | Decode with Generics | Fast, no secret needed, type-safe access to user data. | Low (CPU only). |
| Server-side Route Guard | Verify Signature | Ensures token integrity and authenticity. | Medium (Crypto overhead). |
| Long-lived Sessions | Refresh Token Rotation | Mitigates risk of token theft; allows revocation. | Medium (Storage/DB lookup). |
| High-Security Contexts | Short TTL + Blocklist | Limits window of compromise; enables instant revocation. | High (Increased validation latency). |
| Microservice Communication | Signed JWT + Public Key | Stateless validation across services without shared secrets. | Low (Asymmetric crypto is efficient). |
Configuration Template
Use this template to standardize token types across your TypeScript project.
// src/types/jwt.d.ts
/**
* Global augmentation for JWT claims.
* This file ensures consistent typing across the application.
*/
declare global {
/**
* Base interface for all JWT payloads.
* Enforces presence of temporal claims.
*/
interface JwtBasePayload {
/** Issued At: Unix timestamp in seconds. */
iat: number;
/** Expiration: Unix timestamp in seconds. */
exp: number;
/** JWT ID: Unique identifier for the token. */
jti?: string;
}
/**
* Payload structure for Access Tokens.
* Extend this with application-specific claims.
*/
interface AccessTokenPayload extends JwtBasePayload {
sub: string;
roles: string[];
scope: string;
// Add custom claims here
tenantId?: string;
}
/**
* Payload structure for Refresh Tokens.
* Typically contains minimal data.
*/
interface RefreshTokenPayload extends JwtBasePayload {
sub: string;
sessionId: string;
}
}
export {};
Quick Start Guide
-
Install the library:
npm install jwt-decode
-
Define your payload type:
interface MyPayload {
userId: string;
exp: number;
}
-
Decode safely:
import { jwtDecode } from 'jwt-decode';
const token = 'eyJhbGciOi...'; // Your JWT string
const payload = jwtDecode<MyPayload>(token);
console.log(payload.userId); // Type-safe access
-
Check expiry:
const isExpired = Date.now() >= payload.exp * 1000;
if (isExpired) {
// Trigger refresh flow
}