JWT Tokens Explained: Structure and Use Cases
A JWT (JSON Web Token) is a compact, self-contained token that encodes a JSON payload and a cryptographic signature into a URL-safe string. It's the format behind most modern authentication systems — when you log into a service and it hands you a token, there's a good chance that token is a JWT.
This post breaks down exactly what's inside a JWT, how the signature verification works, when JWTs make sense (and when they don't), and how to decode one without a library. You can paste any JWT into the JWT Decoder to follow along with real values.
What does a JWT actually look like?
JWTs consist of three base64url-encoded sections joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiam9lQGV4YW1wbGUuY29tIiwiaWF0IjoxNzA5NjA4MDAwLCJleHAiOjE3MDk2OTQ0MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Split on the dots and you get:
- Header —
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 - Payload —
eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiam9lQGV4YW1wbGUuY29tIiwiaWF0IjoxNzA5NjA4MDAwLCJleHAiOjE3MDk2OTQ0MDB9 - Signature —
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decode the header with atob() in a browser console (after swapping - → + and _ → / to convert from base64url to standard base64) and you get:
{
"alg": "HS256",
"typ": "JWT"
}
Decode the payload and you get the actual claims — the data the token carries:
{
"sub": "user_123",
"email": "joe@example.com",
"iat": 1709608000,
"exp": 1709694400
}
The sub is the subject (usually a user ID), iat is issued-at (a Unix timestamp), and exp is when the token expires. If you drop 1709694400 into the Timestamp Converter, you'll see that's about 24 hours after issue — a typical session window. If you're unfamiliar with Unix timestamps, use our Timestamp Converter to explore the format.
How does the JWT signature actually work?
The signature is what makes JWTs trustworthy. Without it, anyone could modify the payload and swap in a different user ID.
The signing algorithm (in the example above, HS256 — HMAC-SHA256) works like this:
signature = HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)
The server that issues the token runs this computation with its secret key. When a client sends the token back, the server recomputes the signature using the same key and compares. If they match, the payload hasn't been tampered with. If they don't, the token is rejected.
This is the critical insight: the payload is not encrypted — it's just encoded. Anyone with the token can decode and read the payload. The signature only proves the data hasn't changed. So don't put passwords, credit card numbers, or sensitive PII in a JWT payload unless you're using JWE (JSON Web Encryption) on top of JWS.
What are the standard JWT claim fields?
JWT claims fall into three categories defined by RFC 7519:
Registered claims (reserved names with specific meanings):
iss— issuer (who created the token)sub— subject (who the token is about)aud— audience (who the token is intended for)exp— expiration time (Unix timestamp)nbf— not before (token invalid until this time)iat— issued atjti— JWT ID (unique identifier for the token)
Public claims — registered in the IANA JWT Claims Registry to avoid collisions. Things like email, name, roles.
Private claims — anything you define yourself. Common in practice: user_id, tenant_id, permissions, plan.
None of the registered claims are required. In practice, sub, iat, and exp are almost always present.
Which signing algorithm should you use?
HS256 (HMAC-SHA256) and RS256 (RSA-SHA256) are the two you'll encounter most. They're fundamentally different in one way:
- HS256 uses a single shared secret. Both the token issuer and the verifier need the same key. This is simpler but means anyone who can verify tokens can also issue them.
- RS256 uses a private/public key pair. The issuer signs with a private key. Verifiers use the public key. This means your API servers can verify tokens without ever having the ability to issue them — a meaningful security separation.
RS256 is the right choice if your tokens are verified by services that don't need to create tokens (most backend APIs). HS256 is fine for smaller setups where you control both sides. Avoid HS384/HS512 — the added hash size doesn't improve security enough to matter given HMAC's properties.
ES256 (ECDSA with P-256) is worth knowing about: it gives the security properties of RS256 with much shorter signatures. Auth0 uses it by default for newer applications.
When should you use JWTs?
JWTs are well-suited for:
- Stateless API authentication — the server doesn't need a session store; all auth state lives in the token. Works across multiple servers with no shared session database.
- Short-lived access tokens — set
expto 15 minutes, issue a longer-lived refresh token separately. This is the OAuth 2.0 Bearer Token pattern used by Google, GitHub, Stripe, and most modern APIs. - Service-to-service auth — a backend service generating a signed JWT to call another internal service, avoiding a round-trip to an auth server.
JWTs are a poor fit for:
- Revocation — you can't easily invalidate a JWT before it expires without a server-side blocklist, which defeats the "stateless" point. If you need instant logout or token revocation, you need sessions or short expiry windows with a blocklist.
- Large payloads — JWTs are sent with every request. Stuffing 50 claims in the payload adds overhead. Keep payloads small; load extra data server-side if you need it.
- Storing secrets — again, the payload is readable by anyone holding the token. It's encoded, not encrypted.
How do you decode a JWT without a library?
You don't need jwt.io or a library to read a JWT. In Node.js:
function decodeJWT(token) {
const [header, payload] = token.split('.');
const decode = (str) =>
JSON.parse(atob(str.replace(/-/g, '+').replace(/_/g, '/')));
return {
header: decode(header),
payload: decode(payload),
};
}
In Python 3, using only the standard library:
import base64, json
def decode_jwt(token):
header, payload, _ = token.split('.')
def decode(part):
# Pad to multiple of 4
padded = part + '=' * (-len(part) % 4)
return json.loads(base64.urlsafe_b64decode(padded))
return decode(header), decode(payload)
Note: these only decode — they don't verify the signature. For production use, always verify. In Node.js, the jose library (by Panva) handles verification correctly and is actively maintained. In Python, PyJWT is the standard choice. Never skip signature verification in production; reading a JWT doesn't mean it's trustworthy.
Or paste it straight into the JWT Decoder — it decodes the header and payload instantly and shows expiration in human-readable time.
Quick reference
| Field | What it means | Typical value |
|---|---|---|
alg (header) |
Signing algorithm | HS256, RS256, ES256 |
typ (header) |
Token type | JWT |
sub |
Subject / user ID | "user_123" |
iat |
Issued at (Unix timestamp) | 1709608000 |
exp |
Expires at (Unix timestamp) | 1709694400 |
iss |
Issuer | "https://auth.yourapp.com" |
aud |
Audience | "https://api.yourapp.com" |
jti |
Unique token ID (for revocation) | UUID |
| Algorithm | Type | Key format | Good for |
|---|---|---|---|
| HS256 | Symmetric | Shared secret | Small apps, internal use |
| RS256 | Asymmetric | RSA private/public pair | APIs with separate verify-only services |
| ES256 | Asymmetric | EC P-256 private/public | Modern apps (shorter sigs than RS256) |
To decode a JWT right now, drop it into the JWT Decoder. For expiration timestamps, the Timestamp Converter translates Unix time to a readable date instantly.
Try the tool mentioned in this article:
Open Tool →