JSON Web Tokens show up everywhere in modern software: inside Authorization headers, set as cookies by login services, returned by OAuth providers, and embedded in API responses. When authentication breaks, the first thing any developer reaches for is a jwt debugger: a way to crack open the token and see exactly what is inside. This guide covers everything from the basics of JWT structure through to production-grade debugging patterns, code examples in Node.js and Python, algorithm selection, refresh token architecture, and a troubleshooting decision tree for every common JWT error.
What a JWT actually is
A JWT consists of three Base64URL-encoded sections separated by dots: header.payload.signature. Each section is independently encoded, which is why you can decode the header and payload in any jwt debugger without needing the signing secret. The header names the token type and the algorithm used to sign it. The payload carries the claims: structured data about the user, session, permissions, and token lifetime. The signature binds the header and payload together and proves they have not been modified since the token was issued.
The most important thing to understand about decoding versus verifying: any tool, including a browser-based jwt debugger, can read the header and payload of a JWT without the signing key. The data is encoded, not encrypted. This means a JWT is not a secret container: it is a signed statement. Anyone who intercepts the token can read its contents. The signature only proves authorship and integrity, not confidentiality.
JWT structure:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 <- header (Base64URL)
.
eyJzdWIiOiJ1c2VyXzEyMyIsImlhdCI6MTcxO... <- payload (Base64URL)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ... <- signature
Decoded header:
{ "alg": "HS256", "typ": "JWT" }
Decoded payload:
{ "sub": "user_123", "iat": 1719700000, "exp": 1719786400, "roles": ["admin"] }Standard JWT claims to know
The JWT specification defines a set of registered claim names that every library and framework understands. Custom claims can be added freely alongside these.
- •sub (subject): the unique identifier for the user or entity the token represents. Typically a user ID or account ID.
- •iss (issuer): the server or service that issued the token. Validate this when multiple identity providers may send tokens to your application.
- •aud (audience): the intended recipient. Your API should reject any token whose aud does not match its own identifier.
- •exp (expiry): a Unix timestamp (seconds since epoch) after which the token is invalid. The single most common cause of 401 errors.
- •iat (issued at): when the token was created. Use this to detect unexpectedly old tokens.
- •nbf (not before): the token is invalid before this timestamp. Used for delayed-activation or pre-issued tokens.
- •jti (JWT ID): a unique identifier per token. Store used JTIs server-side to block replay attacks.
Decoding JWTs in Node.js
The jsonwebtoken package is the standard Node.js JWT library. The decode function reads the payload without verifying the signature, which is useful for debugging. The verify function both decodes and validates the signature, expiry, and any other checks you configure. Use decode only for inspection: never for access control.
const jwt = require('jsonwebtoken');
// DECODING (no signature check — debugging only)
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const decoded = jwt.decode(token, { complete: true });
console.log('Header:', decoded.header);
// { alg: 'HS256', typ: 'JWT' }
console.log('Payload:', decoded.payload);
// { sub: 'user_123', exp: 1719786400, iat: 1719700000 }
// Check expiry manually (useful in a jwt debugger context)
const now = Math.floor(Date.now() / 1000);
const isExpired = decoded.payload.exp < now;
console.log('Expired:', isExpired);
console.log('Expires at:', new Date(decoded.payload.exp * 1000).toISOString());
// VERIFYING (signature + expiry + claims checked)
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // explicitly allowlist the algorithm
audience: 'api.myapp.com', // reject tokens for wrong audience
issuer: 'auth.myapp.com', // reject tokens from wrong issuer
});
console.log('Valid token for user:', payload.sub);
} catch (err) {
// err.name tells you exactly what went wrong:
// 'TokenExpiredError' — exp is in the past
// 'JsonWebTokenError' — signature invalid, malformed, or alg mismatch
// 'NotBeforeError' — nbf is in the future
console.error('JWT error:', err.name, err.message);
}A key defensive practice: always pass algorithms explicitly as an array in the verify options. If you omit this, some older library versions will accept whatever algorithm the token header claims, including 'none'. Allowlisting HS256 or RS256 closes that attack vector entirely.
Decoding JWTs in Python
PyJWT is the standard Python library. The pattern is nearly identical to Node.js: decode without verification for inspection, decode with the secret and algorithm list for production verification.
import jwt
import time
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# DECODING without verification (debugging / jwt debugger use)
header = jwt.get_unverified_header(token)
print("Algorithm:", header["alg"]) # HS256
# Decode payload without verifying (options={"verify_signature": False})
payload = jwt.decode(token, options={"verify_signature": False})
print("Subject:", payload.get("sub"))
# Check expiry manually
exp = payload.get("exp", 0)
is_expired = exp < time.time()
print("Expired:", is_expired)
# VERIFYING (signature + expiry + claims checked)
try:
payload = jwt.decode(
token,
key="your-secret-key",
algorithms=["HS256"], # explicit allowlist
audience="api.myapp.com", # validate aud claim
issuer="auth.myapp.com", # validate iss claim
)
print("Valid token for user:", payload["sub"])
except jwt.ExpiredSignatureError:
print("Token has expired")
except jwt.InvalidAudienceError:
print("Token audience does not match")
except jwt.InvalidIssuerError:
print("Token issuer does not match")
except jwt.InvalidSignatureError:
print("Signature verification failed")
except jwt.DecodeError as e:
print("Malformed token:", str(e))JWT algorithm deep-dive: HS256 vs RS256 vs ES256
The alg field in the JWT header is one of the most important fields to inspect in a jwt debugger, because the algorithm controls how the signature is created and verified. Choosing the wrong algorithm for your architecture is a common source of both security vulnerabilities and debugging headaches.
| Algorithm | Type | Key used to sign | Key used to verify | Best for |
|---|---|---|---|---|
| HS256 | Symmetric (HMAC) | Shared secret | Same shared secret | Single-service APIs where both issuer and verifier are the same application |
| RS256 | Asymmetric (RSA) | Private key | Public key | Multi-service architectures where one auth server issues tokens consumed by many APIs |
| ES256 | Asymmetric (ECDSA) | Private key | Public key | Same as RS256 but with smaller keys and faster signing — preferred for new systems |
| HS384 / HS512 | Symmetric (HMAC) | Shared secret | Same shared secret | Same as HS256 with longer hash output — rarely needed for typical web tokens |
| RS384 / RS512 | Asymmetric (RSA) | Private key | Public key | Regulated environments requiring stronger RSA output length |
| none | No signature | None | None | NEVER use in production — any tool can forge a 'none' token |
The critical practical difference: with HS256, every service that verifies a token must hold the same secret. If five microservices all verify tokens, all five need the secret — and any one of them can also issue tokens. With RS256 or ES256, only the auth server holds the private key. Every other service gets a public key to verify tokens but cannot issue new ones. This is why RS256 and ES256 are the right choice for distributed systems.
// HS256: sign and verify with the same secret
const accessToken = jwt.sign(
{ sub: 'user_123', roles: ['editor'] },
process.env.JWT_SECRET, // shared secret — keep this server-side only
{ algorithm: 'HS256', expiresIn: '15m' }
);
// RS256: sign with private key, verify with public key
const { privateKey, publicKey } = require('crypto').generateKeyPairSync('rsa', {
modulusLength: 2048,
});
const rsaToken = jwt.sign(
{ sub: 'user_123' },
privateKey, // only the auth server has this
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Any service can verify with only the public key
const rsaPayload = jwt.verify(rsaToken, publicKey, { algorithms: ['RS256'] });
// ES256: same pattern but with elliptic curve keys (smaller, faster)
const { privateKey: ecPriv, publicKey: ecPub } = require('crypto').generateKeyPairSync('ec', {
namedCurve: 'P-256', // required for ES256
});
const esToken = jwt.sign({ sub: 'user_123' }, ecPriv, { algorithm: 'ES256', expiresIn: '15m' });
const esPayload = jwt.verify(esToken, ecPub, { algorithms: ['ES256'] });OAuth vs JWT: what each one is and when to use which
These two terms are often confused because they appear together. They are completely different things. OAuth is a protocol for authorisation flows — a set of steps for how a user grants an application permission to act on their behalf. JWT is a token format — a way to encode and sign structured data. An OAuth flow often produces a JWT as its output, but OAuth can use other token formats, and JWTs are used in many contexts that have nothing to do with OAuth.
| OAuth 2.0 | JWT | |
|---|---|---|
| What it is | An authorisation protocol (a set of rules for flows) | A token format (a way to structure and sign data) |
| What it does | Defines how a user grants access to their account without sharing their password | Carries signed claims that any recipient can verify without a database lookup |
| Token format | Can produce JWTs, opaque tokens, or other formats | Is itself a token format used by OAuth, OIDC, and many non-OAuth systems |
| Requires server? | Yes — an authorisation server runs the flow | No — verification can happen locally using just a public key or shared secret |
| When to use | Third-party integrations, social login, delegated access (Login with Google) | Any system that needs stateless, verifiable claims passed between services |
| Common combination | OAuth 2.0 + OIDC issues JWTs (ID tokens, access tokens) | JWTs issued directly by your own auth service without an OAuth flow |
In plain terms: if you are building Login with Google, you are using OAuth. If you are building your own login system that issues tokens to your own API, you probably just need JWTs directly without the full OAuth protocol layer.
JWT refresh token patterns
Access tokens are intentionally short-lived (5 to 15 minutes is standard). If a token is stolen, its usefulness is time-limited. But asking users to log in every 15 minutes is unusable. Refresh tokens solve this: they are long-lived tokens (days or weeks) that are used exclusively to request new access tokens, not to access resources directly.
The standard pattern: issue a short-lived access token (JWT) and a long-lived refresh token when the user logs in. Store the refresh token in an HttpOnly cookie (not localStorage). When the access token expires, the client sends the refresh token to a dedicated endpoint to get a new access token. The refresh token itself is rotated: a new refresh token is issued each time it is used, and the old one is invalidated.
// Auth server: issue tokens at login
function issueTokens(userId) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
process.env.ACCESS_SECRET,
{ expiresIn: '15m', algorithm: 'HS256' }
);
// Refresh token: longer-lived, stored server-side for rotation tracking
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh', jti: crypto.randomUUID() },
process.env.REFRESH_SECRET,
{ expiresIn: '30d', algorithm: 'HS256' }
);
// Store refresh token JTI in DB so we can invalidate it later
await db.refreshTokens.create({ userId, jti: decoded.jti, expiresAt });
return { accessToken, refreshToken };
}
// Refresh endpoint: rotate refresh token
async function handleRefresh(refreshToken) {
let decoded;
try {
decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET, {
algorithms: ['HS256'],
});
} catch {
throw new Error('Invalid or expired refresh token');
}
// Check this JTI exists in DB (detect stolen token reuse)
const stored = await db.refreshTokens.findOne({ jti: decoded.jti });
if (!stored) throw new Error('Refresh token already used or revoked');
// Rotate: delete the old refresh token, issue new pair
await db.refreshTokens.delete({ jti: decoded.jti });
return issueTokens(decoded.sub);
}Refresh token rotation with reuse detection is a security pattern, not just a best practice. If a stolen refresh token is used, the legitimate user's next refresh attempt will fail (the JTI is gone), alerting you that the token was compromised. Revoke all tokens for that user and ask them to log in again.
JWT libraries by language
Every major language has a well-maintained JWT library. The pattern across all of them is the same: an encode/sign function and a decode/verify function. The key differences are in how strictly they enforce algorithm allowlisting by default.
| Language | Library | Install | Notes |
|---|---|---|---|
| Node.js | jsonwebtoken | npm install jsonwebtoken | Most widely used. Always pass algorithms array to verify(). Types via @types/jsonwebtoken. |
| Node.js | jose | npm install jose | Modern alternative. Web Crypto API compatible. Works in Edge Runtime and Deno. |
| Python | PyJWT | pip install PyJWT | Standard choice. Pass algorithms=['HS256'] explicitly to decode(). Version 2+ required. |
| Go | golang-jwt/jwt | go get github.com/golang-jwt/jwt/v5 | Official fork of dgrijalva/jwt-go. Use v5 for latest security patches. |
| Java | java-jwt (Auth0) | Maven: com.auth0:java-jwt | Fluent builder API. Supports HS256, RS256, ES256 out of the box. |
| Java | jjwt (Stormpath) | Maven: io.jsonwebtoken:jjwt-api | Widely used alternative. Explicit algorithm required since 0.12. |
| PHP | firebase/php-jwt | composer require firebase/php-jwt | Actively maintained. Pass allowed_algs array to decode(). |
| Ruby | ruby-jwt | gem install jwt | gem 'jwt' in Gemfile. Pass algorithms: ['HS256'] to decode. |
| Rust | jsonwebtoken | cargo add jsonwebtoken | Type-safe. Claims defined as structs. Algorithm hardcoded at compile time. |
A note on library version hygiene: JWT library vulnerabilities are almost always found in the algorithm handling, specifically in how they respond to tokens with alg: none or unexpected algorithm values. Keep your JWT library up to date and always explicitly specify which algorithms you accept.
How to use a jwt debugger effectively
A jwt debugger is a tool that takes a raw JWT string and displays its decoded header and payload in readable form. It should also show you the expiry time in a human-readable format and flag whether the token is currently valid, expired, or not yet active.
When debugging a failing API call, paste the token from your Authorization header into a jwt debugger and check these things in order: first, is the exp timestamp in the past (expired)? Second, does the iss match what your server expects? Third, does the aud match your API identifier? Fourth, is the alg what your verification code expects? Fifth, do the custom claims (roles, permissions, user ID) match what the application is looking for?
AlteredIdea's JWT Debugger runs entirely in your browser. The token you paste is never sent to any server. This matters because a JWT is a credential: treat it the same way you would treat a password.
For tokens signed with RS256 or ES256, a jwt debugger can verify the signature if you provide the public key. For HS256 tokens, you need the shared secret to verify the signature. Decoding (reading the claims) always works without the key.
JWT troubleshooting decision tree
When your JWT authentication is failing, work through these checks in order. Each step tells you exactly what to look at.
- 1.Is the API returning 401 Unauthorized? Start here. Decode the token with a jwt debugger. If you cannot decode it at all, the token is malformed — check the string for truncation, URL encoding issues, or extra whitespace.
- 2.Is exp in the past? Compare the exp value (Unix timestamp) to the current time. If it is expired, your token issuance or caching logic is the problem. Reduce the time between token issuance and use, or implement refresh tokens.
- 3.Does iss match what your server expects? If you have multiple environments (dev, staging, production), tokens issued in one environment are invalid in another because the issuer URL differs.
- 4.Does aud match your API's identifier? An access token issued for api.myapp.com is invalid if your API is checking for app.myapp.com. Audit the exact string your auth server sets versus what your API checks.
- 5.Is the algorithm what your code expects? If your server-side code calls verify with algorithms: ['RS256'] but the token header says alg: HS256, verification will fail. Decode the header first to confirm the actual algorithm.
- 6.Is the signature valid? If claims and expiry look correct but verification still fails, the signing key has changed or a different key is being used. For RS256/ES256, verify you are using the matching public key from the same key pair.
- 7.Is the token being sent correctly? Check that the Authorization header value is exactly Bearer <token> with a single space and no extra characters. Check that the header name is spelled correctly (case-insensitive in HTTP/2, but some proxies are strict).
- 8.Is the token being rejected by a middleware layer before it reaches your code? Check proxy logs, API gateway rules, and CORS preflight responses for early 401s that are not generated by your application.
| Error message | Most likely cause | Fix |
|---|---|---|
| TokenExpiredError | exp claim is in the past | Issue a new token or implement refresh token rotation |
| JsonWebTokenError: invalid signature | Wrong signing secret or key mismatch | Verify the secret/key in your auth server matches what your API uses to verify |
| JsonWebTokenError: jwt malformed | Token string is corrupted, truncated, or URL-encoded | Check how the token is being stored and transmitted — look for encoding issues |
| JsonWebTokenError: invalid algorithm | alg in header does not match your allowed algorithms list | Decode the header to see the actual alg, update your allowlist or re-issue tokens |
| 401 with no error detail | Token not reaching your code | Check proxy/gateway logs, CORS preflight, and middleware order |
| Audience mismatch | aud claim does not match your API identifier | Align the aud value set by your auth server with the audience your API checks |
| Signature verification failed (RS256) | Using wrong public key or token was signed with different private key | Fetch the current public key from your auth server's JWKS endpoint |
| NotBeforeError | nbf claim is in the future | Check for clock skew between issuing server and verifying server — allow a leeway of 60 seconds |
Common JWT security mistakes to avoid
- •Accepting the 'none' algorithm: libraries that do not explicitly allowlist algorithms may accept alg: none, which has no signature. Any tool can forge such a token. Always pass an explicit algorithms array to your verify call.
- •Storing JWTs in localStorage: any JavaScript running on your page can read localStorage, including injected scripts from third-party dependencies or XSS attacks. Store access tokens in memory and refresh tokens in HttpOnly cookies.
- •Not checking exp: if your code decodes the payload but skips expiry validation, tokens remain valid forever after logout or password change. Always let the library check expiry — do not implement it manually.
- •Logging full token values: a JWT in a log file is a credential in a log file. Mask or truncate the signature portion before writing tokens to any log.
- •Trusting decoded claims without verification: calling jwt.decode() gives you the raw payload with no signature check. Never use decoded-but-unverified claims for access control decisions.
- •Using symmetric secrets that are too short: HS256 with a short secret is vulnerable to brute force. Use at least 256 bits (32 bytes) of cryptographically random data as your HMAC secret.
- •Not rotating refresh tokens: a refresh token that is never rotated remains valid indefinitely if stolen. Implement rotation with reuse detection so stolen tokens are caught when the attacker uses them.
AlteredIdea's JWT Debugger decodes any JWT and displays the header, payload, expiry status, and algorithm: entirely in your browser. Paste the token from your Authorization header and get instant, readable output. The token never leaves your device.