Decode a JWT Without a Library in JavaScript
Last updated:
Decoding a JWT payload in JavaScript takes three steps: split the token string on ., take the middle segment (index 1), convert from base64url to standard base64 by replacing - with + and _ with /, call atob(), then JSON.parse the result. The entire operation needs no npm install and runs in any modern browser or Node.js 16+.
The critical caveat: decoding is not verification. Anyone can decode any JWT — the base64url encoding is reversible with no key. Decoding is appropriate for reading claims to display in a UI or debug an auth flow. It is not appropriate for making authorization decisions. That requires cryptographic signature verification on the server.
JWT Structure: Three Base64URL Parts
Every JWT is three base64url-encoded segments separated by dots: HEADER.PAYLOAD.SIGNATURE. A real token looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwNTMxMjIwMCwiZXhwIjoxNzA1Mzk4NjAwfQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cThe three segments carry distinct information:
| Segment | Index | Contains | Decodable as JSON? |
|---|---|---|---|
| Header | 0 | alg, typ, optional kid | Yes |
| Payload | 1 | Claims: sub, exp, iat, custom fields | Yes |
| Signature | 2 | Binary HMAC or RSA/ECDSA bytes | No — binary data |
Only the first two segments are JSON. The third is raw binary output from the signing algorithm — it cannot be parsed as JSON and should not be displayed.
Decoding the Payload with atob()
The minimal, zero-dependency approach uses the built-in atob() function. Split on dots, grab index 1, and parse:
const token =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' +
'.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBbGljZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwNTMxMjIwMCwiZXhwIjoxNzA1Mzk4NjAwfQ' +
'.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
const parts = token.split('.')
const payloadB64 = parts[1]
// Step 1: fix base64url → base64
const base64 = payloadB64.replace(/-/g, '+').replace(/_/g, '/')
// Step 2: decode to a binary string, then parse as JSON
const payload = JSON.parse(atob(base64))
console.log(payload)
// {
// sub: 'user_123',
// name: 'Alice',
// role: 'admin',
// iat: 1705312200,
// exp: 1705398600
// }This runs in any modern browser (Chrome 4+, Firefox 1+, Safari 3+) and in Node.js 16+. For Node.js 14 and earlier, use Buffer instead — see the Node.js section below.
Handling base64url vs base64
Base64url is a URL-safe variant of base64 defined in RFC 4648 §5. It differs in two ways:
| Standard base64 | base64url | Reason |
|---|---|---|
+ | - | + is reserved in URLs |
/ | _ | / is a URL path separator |
Trailing = padding | Padding omitted | = needs percent-encoding in URLs |
The character substitution is mandatory — atob() throws InvalidCharacterError if it encounters - or _. The missing padding is less likely to cause problems in practice because most browser atob() implementations are lenient, but a strict implementation requires padding. A fully robust decode function handles both:
function decodeJwtPart(segment: string): unknown {
// Replace base64url characters with standard base64
const base64 = segment.replace(/-/g, '+').replace(/_/g, '/')
// Add padding if needed (base64url strips trailing '=')
const padded = base64.padEnd(
base64.length + (4 - (base64.length % 4)) % 4,
'='
)
try {
return JSON.parse(atob(padded))
} catch {
throw new Error('Failed to decode JWT segment: invalid base64url or JSON')
}
}
// Usage
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTcwNTM5ODYwMH0.signature'
const [headerSeg, payloadSeg] = token.split('.')
const header = decodeJwtPart(headerSeg) // { alg: 'HS256', typ: 'JWT' }
const payload = decodeJwtPart(payloadSeg) // { sub: 'user_123', exp: 1705398600 }Node.js: Use Buffer Instead of atob()
In Node.js, Buffer.from() accepts 'base64url' as an encoding — no character replacement needed:
// Node.js 10+ — Buffer handles base64url natively
function decodeJwtPayloadNode(token: string): unknown {
const payloadSeg = token.split('.')[1]
const json = Buffer.from(payloadSeg, 'base64url').toString('utf8')
return JSON.parse(json)
}
const payload = decodeJwtPayloadNode(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTcwNTM5ODYwMH0.sig'
)
// { sub: 'user_123', exp: 1705398600 }The 'base64url' encoding argument is available since Node.js 6, but became reliably supported for Buffer.from in Node.js 10+. For universal code that runs in both browser and Node.js, the atob() path with character replacement works in both environments (Node.js 16+).
Checking Expiry from the Payload
The exp claim is a Unix timestamp in seconds (not milliseconds). Compare it to Date.now() / 1000:
interface JwtPayload {
sub: string
iat: number
exp: number
iss?: string
aud?: string | string[]
[key: string]: unknown
}
function getPayload(token: string): JwtPayload {
const seg = token.split('.')[1]
const base64 = seg.replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(atob(base64)) as JwtPayload
}
function isTokenExpired(token: string): boolean {
const payload = getPayload(token)
if (typeof payload.exp !== 'number') return false // no exp claim → never expires
return Date.now() / 1000 > payload.exp
}
function tokenExpiresAt(token: string): Date {
const payload = getPayload(token)
return new Date(payload.exp * 1000)
}
// Example
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTcwNTM5ODYwMH0.sig'
console.log(isTokenExpired(token)) // true or false
console.log(tokenExpiresAt(token).toISOString()) // "2024-01-16T10:30:00.000Z"Common access token lifetimes are 900 seconds (15 min) to 3600 seconds (1 hour). Refresh tokens typically have 604800–2592000 second lifetimes (7–30 days). The iat (issued-at) claim tells you when the token was created; subtracting iat from exp gives you the total token lifetime in seconds.
TypeScript: Typing the Decoded Payload
The minimal safe pattern uses a generic decoder with a type assertion. For production code, use Zod to validate the shape at runtime rather than trusting the assertion.
// Option A: type assertion (fast, no runtime check)
interface StandardClaims {
sub: string
iat: number
exp: number
iss?: string
aud?: string | string[]
jti?: string
nbf?: number
}
// Extend with your app's custom claims
interface AppTokenPayload extends StandardClaims {
role: 'admin' | 'user' | 'guest'
email: string
org?: string
}
function decodePayload<T = StandardClaims>(token: string): T {
const seg = token.split('.')[1]
const base64 = seg.replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(atob(base64)) as T
}
const payload = decodePayload<AppTokenPayload>(token)
console.log(payload.role) // TypeScript knows: 'admin' | 'user' | 'guest'
console.log(payload.email) // string// Option B: Zod validation (runtime-safe, recommended for security-sensitive code)
import { z } from 'zod'
const AppTokenPayloadSchema = z.object({
sub: z.string(),
iat: z.number(),
exp: z.number(),
iss: z.string().optional(),
role: z.enum(['admin', 'user', 'guest']),
email: z.string().email(),
org: z.string().optional(),
})
type AppTokenPayload = z.infer<typeof AppTokenPayloadSchema>
function decodeAndValidatePayload(token: string): AppTokenPayload {
const seg = token.split('.')[1]
const base64 = seg.replace(/-/g, '+').replace(/_/g, '/')
const raw = JSON.parse(atob(base64))
return AppTokenPayloadSchema.parse(raw) // throws ZodError if shape is wrong
}Reading the Header (Algorithm and Key ID)
The header at index 0 uses the same base64url encoding. Reading it tells you which algorithm signed the token and, for asymmetric algorithms, which key was used:
interface JwtHeader {
alg: string // 'HS256' | 'RS256' | 'ES256' | 'EdDSA' | ...
typ: string // 'JWT'
kid?: string // key ID — used to look up the signing key from a JWKS endpoint
}
function decodeHeader(token: string): JwtHeader {
const seg = token.split('.')[0]
const base64 = seg.replace(/-/g, '+').replace(/_/g, '/')
return JSON.parse(atob(base64)) as JwtHeader
}
const header = decodeHeader(token)
// { alg: 'RS256', typ: 'JWT', kid: 'key-2024-01' }
// Use kid to fetch the correct public key from the JWKS endpoint:
// GET https://auth.example.com/.well-known/jwks.json
// Then verify the signature with the key matching header.kidalg value | Algorithm family | Key type | Common in |
|---|---|---|---|
HS256 | HMAC-SHA256 | Shared secret | Same-service tokens |
RS256 | RSA-SHA256 | RSA keypair | OAuth2, OIDC providers |
ES256 | ECDSA P-256 | EC keypair | Mobile, compact signatures |
EdDSA | Ed25519 | EC keypair | Modern stacks, fast verify |
Never use the alg from the header to choose which algorithm to verify with on the server. A known attack ("algorithm confusion") involves modifying the header to specify none or switching from RS256 to HS256. Your server must enforce the expected algorithm independently.
When to Decode vs When to Verify
The decision is not about preference — it is about what you are doing with the result:
| Use case | Action required | Why |
|---|---|---|
| Display username/email in UI | Decode only | Cosmetic — server still validates on each API call |
| Show token expiry countdown | Decode only | UX only — server enforces real expiry |
| Debug auth failures in DevTools | Decode only | Inspection, no trust required |
| Authorize access to a resource | Verify on server | Must confirm token is authentic |
| Check user role/permissions | Verify on server | Claims can be forged without signature check |
| Accept token as proof of identity | Verify on server | Decoding alone provides zero security guarantee |
For server-side verification in Node.js, use jose (universal, maintained) or jsonwebtoken (Node.js only). The jose library's jwtVerify validates the signature, checks exp, iss, and aud, and returns a typed payload — all in one call. Its decodeJwt() function provides a safe, edge-case-handling decode without verification for the display use cases above.
// jose: decode without verification (safe for UI display)
import { decodeJwt } from 'jose'
const payload = decodeJwt(token)
// Returns typed JWTPayload — handles all base64url edge cases
// jose: verify + decode (required for security decisions)
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const { payload } = await jwtVerify(token, secret, {
issuer: 'https://auth.example.com',
audience: 'api.example.com',
})
// payload is typed, signature verified, exp/iss/aud checkedDefinitions
- base64url
- A URL-safe variant of base64 encoding defined in RFC 4648 §5 that substitutes
-for+and_for/, and omits trailing=padding characters. JWTs use base64url for all three segments to make the token safe to include in URLs and HTTP headers without percent-encoding. - JWT payload
- The second of the three dot-separated segments in a JWT, containing a JSON object of claims. Claims are key-value pairs asserting facts about the subject — such as user ID (
sub), expiry time (exp), and issuer (iss). The payload is base64url-encoded but not encrypted, so any party with the token string can read its contents. - exp claim
- A registered JWT claim defined in RFC 7519 that holds a Unix timestamp in seconds representing the expiration time of the token. After this timestamp, the token must be rejected. Servers must validate
expduring signature verification. Client-side expiry checks (usingDate.now() / 1000 > payload.exp) are for UX purposes only and carry no security weight. - signature verification
- The cryptographic process of confirming that a JWT's signature matches the header and payload, proving the token was signed by the expected party and has not been modified in transit. For HS256 tokens, verification uses the shared secret; for RS256 and ES256 tokens, it uses the issuer's public key. Decoding reveals the payload contents but performs no signature verification.
- atob()
- A built-in browser (and Node.js 16+) function that decodes a standard base64 string into a binary string. The name stands for "ASCII to binary." Because JWTs use base64url rather than standard base64, the
-and_characters must be replaced with+and/before passing a JWT segment toatob(), or the function throwsInvalidCharacterError.
FAQ
How do you decode a JWT payload without a library in JavaScript?
Split the token on ., take index 1, replace - with + and _ with / to convert from base64url to standard base64, pass to atob(), then JSON.parse the result: JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))). This works in modern browsers and Node.js 16+ with no npm install.
Why does atob() fail on some JWTs?
JWTs use base64url encoding, which replaces + with - and / with _. The atob() function only handles standard base64. If the JWT segment contains - or _, atob() throws InvalidCharacterError. Fix: replace those characters before calling atob(). Omitted trailing = padding is also common — add it back if strict implementations complain.
Is decoding a JWT the same as verifying it?
No. Decoding unpacks the base64url encoding — it requires no key and provides no security. Anyone with the token string can decode it. Verifying checks the cryptographic signature using the server secret or issuer public key, confirming the token is authentic and unmodified. Never use a decoded payload for authorization decisions without server-side signature verification.
How do you check if a JWT is expired without a library?
Decode the payload and compare the exp claim to Date.now() / 1000: Date.now() / 1000 > payload.exp returns true if expired. The exp claim is in seconds, not milliseconds. This check is fine for UI purposes — for security decisions, rely on the server's expiry check during signature verification.
How do you type the JWT payload in TypeScript?
Define an interface with the standard claims (sub, iat, exp, optional iss, aud) plus your custom claims. Add an index signature [key: string]: unknown to accept additional claims without losing type safety on the known fields. For runtime safety, validate the decoded object with Zod instead of relying on a type assertion.
How do you decode the JWT header without a library?
The header is index 0 after splitting on dots. Apply the same base64url fix and JSON.parse: JSON.parse(atob(token.split(".")[0].replace(/-/g,"+").replace(/_/g,"/"))). The header contains alg (the signing algorithm) and typ ("JWT"), plus an optional kid (key ID) for JWKS-based key lookup.
Should I use the jose library or decode manually?
For display-only use cases (showing a username in a UI, debugging), manual decoding with atob() is fine. The jose library's decodeJwt() handles all edge cases correctly and is a better default for production code. For any security-sensitive path — checking permissions, accepting a token as proof of identity — always use jwtVerify() (or equivalent) server-side, which validates the signature, expiry, issuer, and audience.
Does decoding a JWT work in Node.js without atob()?
Yes. In Node.js, use Buffer.from(segment, 'base64url').toString('utf8'). The 'base64url' encoding argument handles the - and _ characters natively — no manual replacement needed. This is the idiomatic Node.js approach and works from Node.js 10+. For Node.js 16+, atob() is also available globally.
Decode a JWT now
Paste any JWT into Jsonic's JWT Decoder to instantly see the header, payload, and expiry time formatted as human-readable JSON. Everything runs client-side — nothing is sent to any server.
Open JWT DecoderFurther reading and primary sources
- JWT.io Debugger — Online JWT decoder, verifier, and generator
- RFC 7519 JWT Spec — Official JSON Web Token specification
- jose library — Universal JOSE library with decodeJwt() and verification