Introduction
If you run services in production, you have almost certainly stared at a JWT at 2 a.m. wondering why a request is being rejected with a 401 Unauthorized. JSON Web Tokens (JWTs) are the default currency of authentication in modern distributed systems — they carry identity between your API gateway, your microservices, and your service mesh. When auth breaks, the token is usually the first thing you need to open up and read.
This guide shows you how to decode a JWT the right way, from a quick command-line one-liner to production-grade Python and Node.js. More importantly, it explains the single most misunderstood fact about JWTs: decoding is not the same as verifying. Getting that distinction wrong is how security incidents happen.
What a JWT Actually Is
A JWT defined by RFC 7519 is just three Base64URL-encoded strings joined by dots:
header.payload.signature
- Header — metadata: the signing algorithm (
alg, e.g.RS256) and token type (typ). - Payload — the claims: who the subject is (
sub), who issued it (iss), who it is for (aud), and when it expires (exp). - Signature — a cryptographic signature over the header and payload, produced with a secret (HMAC) or a private key (RSA/ECDSA).
A critical property: the header and payload are encoded, not encrypted. Anyone who holds the token can read every claim inside it. The signature does not hide the data — it only proves the token has not been tampered with. That is why you must never put secrets like passwords or API keys inside a JWT payload.
Decoding a JWT on the Command Line
Because each segment is Base64URL, you can decode a token with tools you already have on any Linux box. Base64URL differs from standard Base64 in two ways: it uses - and _ instead of + and /, and it strips trailing = padding. Here is a portable shell snippet that handles both:
# Decode the payload (second segment) of a JWT
decode_jwt() {
local part=$(echo -n "$1" | cut -d. -f2)
# restore base64url -> base64 and pad to a multiple of 4
local pad=$(( (4 - ${#part} % 4) % 4 ))
part="${part}$(printf '%*s' "$pad" | tr ' ' '=')"
echo -n "$part" | tr '_-' '/+' | base64 -d | jq .
}
decode_jwt "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3ODMwMDAwMDB9.sig"
Piping the result through jq gives you nicely formatted claims. To read the header instead, change cut -d. -f2 to -f1. This is invaluable inside a CI pipeline or a debugging container where installing extra packages is painful.
Decoding a JWT in Python
For scripting and automation, PyJWT is the standard library. Install it with pip install pyjwt.
import jwt
token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3ODMwMDAwMDB9.sig"
# Decode WITHOUT verifying the signature - inspection only
claims = jwt.decode(token, options={"verify_signature": False})
print(claims) # {'sub': '123', 'exp': 1783000000}
# Read the header separately
print(jwt.get_unverified_header(token)) # {'alg': 'HS256', 'typ': 'JWT'}
When you actually need to trust the token — not just read it — you verify the signature and pin the allowed algorithms:
try:
claims = jwt.decode(
token,
key="your-shared-secret",
algorithms=["HS256"], # never trust the token's own alg blindly
audience="my-service",
)
except jwt.ExpiredSignatureError:
print("Token has expired")
except jwt.InvalidTokenError as e:
print(f"Invalid token: {e}")
Pinning algorithms explicitly defends against the classic alg: none and algorithm-confusion attacks, where an attacker swaps RS256 for HS256 to trick your server into verifying with the public key as an HMAC secret.
Decoding a JWT in Node.js
In the JavaScript ecosystem, jsonwebtoken is the go-to package (npm install jsonwebtoken).
const jwt = require('jsonwebtoken');
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3ODMwMDAwMDB9.sig';
// Decode only - returns the claims without checking the signature
const decoded = jwt.decode(token, { complete: true });
console.log(decoded.header); // { alg: 'HS256', typ: 'JWT' }
console.log(decoded.payload); // { sub: '123', exp: 1783000000 }
// Verify - throws if signature is bad or the token is expired
try {
const claims = jwt.verify(token, 'your-shared-secret', { algorithms: ['HS256'] });
console.log('Valid:', claims);
} catch (err) {
console.error('Rejected:', err.message);
}
Note the same pattern as Python: decode() is a read operation, verify() is a trust operation. Never use decode() to make an authorization decision.
When You Need to Decode a JWT in a DevOps Workflow
Decoding tokens is a daily reality once you operate microservices. The most common situations:
- Debugging a 401 in a microservice call. Service A calls Service B and gets rejected. Decode the token A is sending and check the
audclaim — a mismatched audience is the number one cause of silent service-to-service auth failures. - Checking expiry and clock skew. Decode the
expandiatclaims and compare them to the current epoch time. If your nodes' clocks drift, freshly issued tokens can appear expired. This ties directly into the reliability practices covered in our incident management runbook template. - Inspecting claims in a CI/CD pipeline. OIDC tokens issued to pipeline jobs (for example, to authenticate to a cloud provider without long-lived keys) carry claims like
repositoryandref. Decoding them helps you write correct trust policies. - Service-mesh and API-gateway auth. In Kubernetes, an Istio or Envoy sidecar validates JWTs before traffic reaches your pod. When requests are dropped, decoding the token tells you whether the
issmatches the mesh's configured issuer. Getting this right is part of broader Kubernetes security best practices.
AI-Assisted Token Debugging
Modern AIOps platforms increasingly parse auth failures automatically. When a spike of 401s hits your gateway, an AI triage agent can decode the offending tokens, correlate a common expired iss or malformed aud across requests, and surface the root cause before a human opens a terminal. This is the same observability-driven pattern we explore in AI-powered observability — the agent reads the claims, you approve the fix.
Common JWT Decoding Errors (and How to Fix Them)
Decoding looks simple until a token refuses to parse or a verifier rejects a token you are certain is valid. These are the failures you will actually hit in production, and how to diagnose each one fast.
Base64 Padding Errors
The error looks like Invalid base64-encoded string or Incorrect padding. It happens because JWTs use Base64URL, which strips the trailing = characters that standard Base64 decoders expect. If you feed a raw segment straight into base64 -d or Python's base64.b64decode, it chokes on the missing padding.
The fix is to re-add padding before decoding. In Python, PyJWT handles this for you, but if you decode by hand:
import base64, json
def decode_segment(seg: str) -> dict:
padding = "=" * (-len(seg) % 4) # restore missing padding
raw = base64.urlsafe_b64decode(seg + padding)
return json.loads(raw)
Use urlsafe_b64decode, not b64decode — the former understands the - and _ alphabet. The same rule is why the shell snippet earlier ran tr '_-' '/+' before piping to base64 -d.
The alg: none and Algorithm-Confusion Trap
This is a security bug, not just a parsing error. Early JWT libraries honored an alg header of none, meaning "no signature required." An attacker strips the signature, sets alg to none, and your verifier accepts a forged token. A related attack swaps RS256 for HS256 so the verifier uses the public RSA key as an HMAC secret — which the attacker also knows.
The defense is always the same: pin the algorithm on the verifier side and never derive it from the token.
# WRONG - trusts whatever the token declares
jwt.decode(token, key, algorithms=jwt.get_unverified_header(token)["alg"])
# RIGHT - the server dictates the algorithm
jwt.decode(token, key, algorithms=["RS256"])
A modern PyJWT or jsonwebtoken release rejects alg: none by default, but only if you pass an explicit algorithms allowlist. Never omit it.
Clock Skew on exp and nbf
A token that is genuinely valid gets rejected with ExpiredSignatureError or ImmatureSignatureError because the verifying node's clock disagrees with the issuer's. The exp (expiry) and nbf (not-before) claims are absolute Unix timestamps, so even a few seconds of drift on a node without NTP can reject fresh tokens or briefly honor stale ones.
Decode the token, read exp and nbf, and compare them to the node's own date +%s. If they are close to the boundary, the real fix is NTP — but most libraries also let you allow a small tolerance:
jwt.decode(token, key, algorithms=["HS256"], leeway=30) # 30s of skew tolerance
Keep the leeway small (10–30 seconds). A large window weakens the security guarantee that expiry is supposed to provide. Recurring skew is a classic finding in postmortems, which is exactly why it belongs in your runbook checklist.
Invalid Signature on a Correct-Looking Token
The claims decode cleanly but verify throws InvalidSignatureError. The usual causes: the wrong key or secret, a key that has rotated, or an encoding mismatch (verifying against the base64 of a secret instead of its raw bytes). When the payload looks right but the signature fails, stop debugging the token and start debugging your key material — you are almost always comparing against the wrong key.
The Security Caveat You Cannot Skip
Two rules protect you from turning a debugging session into an incident.
Decoding is not verifying. Reading the claims out of a token tells you what it says, not whether it is authentic. An attacker can craft a token with "sub": "admin" and any expiry they like. Only signature verification against a trusted key proves the token was issued by someone you trust. Every authorization decision in your code must go through verify, never decode. The OWASP JWT guidance treats this as a primary control.
Never paste a production token into a random website. A live JWT is a bearer credential — whoever holds it can act as that user until it expires. Pasting a production token into an unknown online decoder ships that credential to a third-party server you do not control. It may be logged, cached, or replayed. Treat a leaked token exactly as you would treat a leaked password: as an incident that burns into your error budget and demands rotation.
The safe alternative is a decoder that runs entirely in your browser, where the token is parsed in local JavaScript and never sent over the network.
Decode a JWT Safely, Right Now
When you just need to crack open a token and read its claims, use our free JWT Decoder. It decodes the header and payload instantly in your browser — the token never leaves your device, so it is safe to use even with sensitive tokens. Paste, read the claims, check the exp, and get back to shipping.
Conclusion
A JWT is three Base64URL segments carrying a header, a set of claims, and a signature. You can decode the readable parts with a shell one-liner, PyJWT, or Node's jsonwebtoken — all useful when you are chasing a 401 through a mesh of microservices. But keep the golden rule front of mind: decoding reveals what a token claims, and only verification proves it is real. Read tokens freely for debugging, verify them always for trust, and never hand a live token to a server you do not own.
References
- RFC 7519 — JSON Web Token (JWT) specification: https://datatracker.ietf.org/doc/html/rfc7519
- Auth0 — JWT Handbook and documentation: https://auth0.com/docs/secure/tokens/json-web-tokens
- OWASP — JSON Web Token Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html