JWT Validation in Node.js — Structure, Claims, and Expiry Pitfalls
JWTs are everywhere in modern auth — and misunderstood almost as often. Here's what every part of a token means, which claims matter for security, and how to inspect tokens without a secret key.
In this guide
1. What is a JWT?
A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe way to represent claims between two parties, defined by RFC 7519. JWTs are widely used as access tokens in OAuth 2.0 and OpenID Connect flows, as session tokens in stateless APIs, and for information exchange between microservices.
A JWT looks like three base64url-encoded strings separated by dots. Unlike opaque tokens (random strings your server looks up in a database), JWTs are self-contained — the claims are embedded in the token and can be read by anyone who holds it.
2. JWT anatomy — header, payload, signature
A JWT consists of exactly three parts, each base64url-encoded and joined by dots:
Header
Describes the token type and the signing algorithm used.
{ "alg": "HS256", // Signing algorithm: HS256, RS256, ES256, etc. "typ": "JWT" // Token type (always "JWT") }
Payload
Contains the claims — statements about the subject (user) and metadata about the token.
{ "sub": "user_123", // Subject: who the token is about "name": "Alice", // Custom claim "iat": 1700000000, // Issued at (Unix timestamp) "exp": 1700003600 // Expires at (Unix timestamp) }
Signature
Created by signing base64url(header) + "." + base64url(payload) with the secret (HMAC) or private key (RSA/EC). Verifying the signature requires the secret or public key — the IsValid API parses the structure and checks expiry without verifying the signature (useful for debugging).
3. Standard claims — what they mean
RFC 7519 defines a set of registered claim names. You don't have to use them all, but using the standard names makes your tokens interoperable with existing libraries and frameworks.
| Claim | Full name | Meaning |
|---|---|---|
| iss | Issuer | Who created and signed the token (e.g. "https://auth.example.com") |
| sub | Subject | Who the token is about — typically a user ID |
| aud | Audience | Who the token is intended for — your API should validate this |
| exp | Expiration | Unix timestamp after which the token must not be accepted |
| nbf | Not Before | Unix timestamp before which the token must not be accepted |
| iat | Issued At | Unix timestamp when the token was issued |
| jti | JWT ID | Unique identifier for this token — useful for revocation |
4. Common JWT mistakes in Node.js
Accepting the "none" algorithm
JWT libraries that accept any algorithm allow an attacker to craft a token with "alg": "none" and no signature, bypassing authentication entirely. Always specify the allowed algorithms explicitly.
// ❌ NEVER do this — accepts "none" algorithm jwt.verify(token, secret); // ✅ Restrict to your expected algorithm jwt.verify(token, secret, { algorithms: ['HS256'] });
Not checking the audience (aud)
A token issued for your user-facing API should not be accepted by your admin API. Always verify that aud matches your service's expected value.
// ✅ Validate audience jwt.verify(token, secret, { algorithms: ['RS256'], audience: 'https://api.myapp.com', });
Clock skew causing valid tokens to be rejected
Servers in distributed systems can have clocks slightly out of sync. A token issued at iat: now on one server may arrive at a second server where now is a few seconds in the past, causing the token to be rejected as "not yet valid" (nbf check). Add a small clock tolerance.
// ✅ Allow 30 seconds of clock skew jwt.verify(token, secret, { clockTolerance: 30 });
Trusting the header for key selection
If your library uses the kid (key ID) from the header to look up the public key, an attacker can craft a token with any kid to trigger unexpected behaviour. Validate the kid against a known-good allowlist from your JWKS endpoint.
5. Parsing and inspecting tokens with the API
The IsValid JWT API parses the structure, decodes the header and payload, and returns the algorithm, issued-at, expires-at, and whether the token is currently expired — all without needing your secret or private key.
This is useful for debugging tokens in development, logging token metadata on the server side, or inspecting third-party tokens from identity providers (e.g. Google, Auth0, Cognito) to check their structure before forwarding them.
Full parameter reference and response schema: JWT API docs →
6. Node.js code example
Using the native fetch API (Node 18+). No dependencies required.
// jwtInspector.js const API_KEY = process.env.ISVALID_API_KEY; const BASE_URL = 'https://api.isvalid.dev'; /** * Parse and inspect a JWT using the IsValid API. * Does NOT verify the signature. * * @param {string} token - The JWT string (three dot-separated base64url parts) * @returns {Promise<object>} Parsed token info */ async function inspectJwt(token) { const params = new URLSearchParams({ value: token }); const response = await fetch(`${BASE_URL}/v0/jwt?${params}`, { headers: { Authorization: `Bearer ${API_KEY}` }, }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(`JWT API error ${response.status}: ${error.message ?? 'unknown'}`); } return response.json(); } // ── Example usage ──────────────────────────────────────────────────────────── const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' + '.eyJzdWIiOiJ1c2VyXzEyMyIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAzNjAwfQ' + '.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; const result = await inspectJwt(token); if (!result.valid) { console.log('Malformed JWT — not a valid token structure'); } else { console.log('Algorithm:', result.algorithm); console.log('Subject:', result.payload.sub); console.log('Issued at:', result.issuedAt); console.log('Expires at:', result.expiresAt); console.log('Expired:', result.expired); }
In a middleware you might log token metadata without re-decoding:
// middleware/logTokenMeta.js export async function logTokenMeta(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) return next(); const token = authHeader.slice(7); try { const info = await inspectJwt(token); if (info.valid) { // Log non-sensitive metadata — do NOT log the token itself req.log.info({ tokenSub: info.payload.sub, tokenAlg: info.algorithm, tokenExp: info.expiresAt, tokenExpired: info.expired, }, 'incoming request token'); } } catch { // Parsing failed — don't block the request, let auth middleware handle it } next(); }
result.expired for quick expiry dashboards or debugging without adding a crypto library to your tooling scripts. For production auth, always use a proper JWT library with signature verification.7. cURL example
Inspect a JWT (use --data-urlencode to avoid encoding issues with the dots and base64 characters):
curl -G -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "value=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoxNzAwMDAzNjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" \ "https://api.isvalid.dev/v0/jwt"
Test with a malformed token (not three parts):
curl -G -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "value=notavalidjwt" \ "https://api.isvalid.dev/v0/jwt"
8. Understanding the response
Valid, non-expired token:
{ "valid": true, "algorithm": "HS256", "header": { "alg": "HS256", "typ": "JWT" }, "payload": { "sub": "user_123", "name": "Alice", "iat": 1700000000, "exp": 1700003600 }, "issuedAt": "2023-11-14T22:13:20.000Z", "expiresAt": "2023-11-14T23:13:20.000Z", "expired": false }
Expired token (structure still valid):
{ "valid": true, "algorithm": "HS256", "header": { "alg": "HS256", "typ": "JWT" }, "payload": { "sub": "user_123", "iat": 1600000000, "exp": 1600003600 }, "issuedAt": "2020-09-13T12:26:40.000Z", "expiresAt": "2020-09-13T13:26:40.000Z", "expired": true }
| Field | Type | Description |
|---|---|---|
| valid | boolean | Token has 3 parts, both header and payload decode to valid JSON with the required fields |
| algorithm | string | Signing algorithm from the header (e.g. HS256, RS256, ES256) |
| header | object | Decoded JWT header object |
| payload | object | Decoded JWT payload object with all claims |
| issuedAt | string | null | ISO 8601 timestamp from the iat claim, or null if absent |
| expiresAt | string | null | ISO 8601 timestamp from the exp claim, or null if absent |
| expired | boolean | null | Whether the token has passed its exp timestamp, or null if no exp claim |
9. Edge cases and security notes
Tokens without an expiry claim
If the token has no exp claim, expired is null and expiresAt is null. Non-expiring tokens are a security risk — a stolen token is valid forever. Always issue tokens with an appropriate exp.
if (result.expired === null) { // Token has no expiry — treat with caution console.warn('Token has no exp claim — consider rejecting non-expiring tokens'); }
Do not send sensitive tokens to third-party services
For tokens that carry sensitive user data in their payload, avoid sending the full token to any external API — including this one. In development and debugging scenarios you can scrub the payload before logging, or use the inspectJwt helper only with test tokens. In production, decode tokens locally using jsonwebtoken or jose.
JWE (encrypted JWTs)
JWE tokens look like 5-part strings (not 3). The payload is encrypted, not just base64-encoded. The API will return valid: false for JWE tokens since they have a different structure from JWS (signed) tokens. If you work with JWE, decrypt it first using your key before inspecting the claims.
Token in query string — never
Passing a JWT as a URL query parameter causes it to appear in server access logs, browser history, and Referer headers. Always pass tokens in the Authorization: Bearer header or in a POST body. The value parameter of the IsValid API is an inspection utility — not an auth pattern to replicate.
Summary
See also
Inspect JWT tokens instantly
Free tier includes 100 API calls per day. No credit card required. Supports all standard JWT signing algorithms.