Zero-Dependency JWT Decoding in TypeScript
Node 16+ ships with the base64url encoding on Buffer. In the browser, WebCrypto is available but you can also use plain atob with a small character swap.
// Types for the decoded claims
interface Claims {
sub: string;
iat: number;
exp: number;
iss?: string;
aud?: string | string[];
[k: string]: unknown;
}
// Node 16+
export function decodePayloadNode(token: string): Claims {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("Not a JWT");
const json = Buffer.from(parts[1], "base64url").toString("utf-8");
return JSON.parse(json) as Claims;
}
// Browser
export function decodePayloadBrowser(token: string): Claims {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("Not a JWT");
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const pad = base64.length % 4 === 0 ? "" : "=".repeat(4 - (base64.length % 4));
const json = atob(base64 + pad);
return JSON.parse(json) as Claims;
}
// Usage
const claims = decodePayloadNode("eyJhbGciOi...");
console.log(claims.sub, claims.exp);Use for logging, debugging, developer tools, and client-side expiration checks. Never for authorization.
Using panva/jose — The Modern TypeScript Standard
Filip Skokan's jose library. First-class TypeScript, zero runtime dependencies, WebCrypto-based, runs in every JavaScript runtime.
Installation
npm i jose
// tsconfig.json — jose targets ES2020+
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true
}
}Typed Claims Interface
import type { JWTPayload } from "jose";
// Extend the base JWTPayload with your custom claims
export interface AppJwtClaims extends JWTPayload {
sub: string; // Required in your app
email: string;
roles: string[];
org_id: string;
// The following are inherited from JWTPayload (all optional):
// iss?, sub?, aud?, exp?, nbf?, iat?, jti?
}
// Type guard for custom claims
export function hasRole(claims: AppJwtClaims, role: string): boolean {
return Array.isArray(claims.roles) && claims.roles.includes(role);
}Verify with HS256 Secret
import { jwtVerify, errors } from "jose";
import type { AppJwtClaims } from "./types";
const HS_KEY = new TextEncoder().encode(
process.env.JWT_SECRET ?? (() => { throw new Error("JWT_SECRET missing"); })()
);
export async function verifyToken(token: string): Promise<AppJwtClaims> {
try {
const { payload } = await jwtVerify<AppJwtClaims>(token, HS_KEY, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["HS256"],
clockTolerance: "30s", // Reasonable drift tolerance
requiredClaims: ["sub", "exp"],
});
return payload;
} catch (err) {
if (err instanceof errors.JWTExpired) {
throw new Error("Token expired");
}
if (err instanceof errors.JWTClaimValidationFailed) {
throw new Error(`Claim failed: ${err.claim}`);
}
if (err instanceof errors.JWSSignatureVerificationFailed) {
throw new Error("Bad signature");
}
throw err;
}
}Verify with RS256 (Public Key from PEM)
import { jwtVerify, importSPKI } from "jose";
import { readFileSync } from "fs";
const pubPem = readFileSync("public.pem", "utf-8");
const publicKey = await importSPKI(pubPem, "RS256");
const { payload } = await jwtVerify<AppJwtClaims>(token, publicKey, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["RS256"], // Explicit — never allow HS with an RSA key
});Verify with JWKS (Auth0, Cognito, Azure AD)
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json"),
{
cacheMaxAge: 600_000, // 10 minutes
cooldownDuration: 30_000, // 30-second cooldown between refetches
}
);
export async function verifyWithJwks<T extends JWTPayload>(token: string): Promise<T> {
const { payload } = await jwtVerify<T>(token, JWKS, {
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["RS256"],
});
return payload;
}NestJS JWT Guard
// npm i @nestjs/jwt @nestjs/passport passport passport-jwt
// npm i -D @types/passport-jwt
// auth.strategy.ts
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
interface JwtPayload {
sub: string;
email: string;
roles: string[];
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET!,
issuer: "https://auth.example.com",
audience: "https://api.example.com",
algorithms: ["HS256"],
});
}
async validate(payload: JwtPayload) {
// Whatever this returns is attached to req.user
return { userId: payload.sub, email: payload.email, roles: payload.roles };
}
}
// users.controller.ts
import { Controller, Get, UseGuards, Request } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Controller("users")
@UseGuards(AuthGuard("jwt"))
export class UsersController {
@Get("me")
me(@Request() req: { user: { userId: string; email: string } }) {
return req.user;
}
}Next.js Edge Runtime Middleware
// middleware.ts (project root, next to app/ or pages/)
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function middleware(req: NextRequest) {
const token = req.cookies.get("token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
}
try {
const { payload } = await jwtVerify(token, SECRET, {
issuer: "https://auth.example.com",
});
const res = NextResponse.next();
res.headers.set("x-user-id", payload.sub!);
return res;
} catch {
return NextResponse.redirect(new URL("/login", req.url));
}
}
export const config = {
matcher: ["/dashboard/:path*", "/api/protected/:path*"],
};Common jose Errors
- errors.JWTExpired — exp claim is in the past. Refresh the token.
- errors.JWTClaimValidationFailed — a claim (iss, aud, sub) did not match your options. err.claim tells you which.
- errors.JWSSignatureVerificationFailed — signature does not verify against the key.
- errors.JWSInvalid — token is malformed (not three dot-separated segments, invalid base64url).
- errors.JOSEAlgNotAllowed — token alg is not in your
algorithmsoption array. Alg confusion defence. - errors.JWKSNoMatchingKey — token kid does not match any key in the JWKS.
Security Best Practices for TypeScript JWT
- Always pass
algorithms: ["HS256"]oralgorithms: ["RS256"]as an array. Never let the token pick its own algorithm. - Set
strict: truein tsconfig.json. The typed Claims interface catches missing claim bugs at compile time. - Use
clockTolerance: "30s". Do not exceed 60 seconds. - HS256 secret must be at least 32 random bytes. Generate with
crypto.randomBytes(32).toString("hex"). Store in env. - In Next.js Edge Runtime, only jose works (WebCrypto). jsonwebtoken relies on Node crypto and will not run at the edge.
- In the browser, decode-only. Never verify — the secret would be public. Use jwt-decode for lightweight browser inspection.
Key Facts
- Modern lib:
- jose (panva/jose) — TypeScript-native, works everywhere
- Legacy lib:
- jsonwebtoken + @types/jsonwebtoken (Node CommonJS only)
- Browser decode:
- jwt-decode (auth0/jwt-decode) — 850 bytes gzipped
- HS256 secret:
- 32+ random bytes. crypto.randomBytes(32).toString("hex")
- JWKS:
- createRemoteJWKSet in jose — auto cache + rotate
Related JWT Tools
- JWT Decoder Online — decode any token instantly
- JWT Decoder JavaScript — plain JS patterns
- JWT Decoder Node.js — jsonwebtoken and jose
- JWT Decoder Python — PyJWT for Flask/Django/FastAPI
- JWT Decoder Go — golang-jwt/jwt v5