Self-contained signed tokens that carry claims — no database lookup required.
If you are new here: A JWT (JSON Web Token, pronounced "jot") is the most widely used token format on the internet. It's what OIDC id_tokens are made of, what many APIs use as access tokens, and what your Authorization: Bearer header often contains. A JWT is a self-contained, cryptographically signed package of claims — statements about a user like "this is Alice, she has the admin role, and this token expires at 5pm." The self-contained part is what makes JWTs powerful: any server can verify a JWT entirely locally by checking the cryptographic signature — no database call, no network roundtrip to the auth server. This is why JWTs scale so well in distributed systems. Understanding JWTs means understanding both their power and their pitfalls — including some common mistakes that introduce serious security vulnerabilities.
| Term | Plain meaning |
|---|---|
| JWT | JSON Web Token — a base64url-encoded, signed JSON structure in three parts |
| Claim | A statement inside the JWT payload — e.g., "sub": "user-7", "role": "admin" |
| Header | The first JWT segment — specifies the signing algorithm (alg) and type (typ) |
| Payload | The second segment — the JSON object containing claims |
| Signature | The third segment — a cryptographic hash of header + payload, produced with a key |
| RS256 | RSA signing with SHA-256 — uses a private/public key pair; the auth server signs, any service verifies with the public key |
| HS256 | HMAC-SHA256 — uses a shared secret; simpler but requires every service to hold the secret |
exp | Expiration claim — Unix timestamp after which the token must be rejected |
sub | Subject claim — identifies the principal (usually a user ID) |
iss | Issuer claim — identifies who issued the token (e.g., https://accounts.google.com) |
Before JWTs, APIs typically used opaque tokens — random strings like xK9mP2... that mean nothing on their own. When a request arrives with an opaque token, the server must:
Authorization headerxK9mP2... belong to? What are their permissions? Has it expired?"This works fine for a single server, but in a microservices environment where a single user request might touch 10 services, each service makes its own token lookup call. Multiply 10 services × thousands of requests per second = enormous load on the auth service.
JWTs solve this by encoding the user's claims inside the token. The auth service issues the token once; every other service verifies it locally using the auth service's public key.
In plain terms: an opaque token is like a coat check ticket — it's just a number, meaningless without the coat check records. A JWT is like a driver's license — it contains your information (name, age, address), and anyone with a reference card (the public key) can verify its authenticity without calling the DMV.
A JWT looks like this (three base64url segments joined by dots):
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI3IiwicmxlIjoiYWRtaW4iLCJleHAiOjE3MDAwMDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Part 1 — Header:
{ "alg": "RS256", "typ": "JWT" }Specifies the signing algorithm (RS256 = RSA + SHA-256) and the token type.
Part 2 — Payload (Claims):
{
"sub": "7",
"role": "admin",
"email": "[email protected]",
"exp": 1700000000,
"iat": 1699996400,
"iss": "https://auth.myapp.com"
}The claims. Standard claims: sub (subject/user ID), exp (expiration), iat (issued at), iss (issuer), aud (intended audience). Custom claims: whatever your app needs (role, tenantId, permissions, etc.).
Part 3 — Signature:
RSASHA256(base64url(header) + "." + base64url(payload), privateKey)
Critical: the payload is only base64url-encoded, not encrypted. Anyone who has the token can decode and read the payload — atob(payload) in any browser. Do not put sensitive information in the payload (passwords, SSNs, secrets). The signature only ensures the payload hasn't been tampered with — it doesn't hide the contents.
In plain terms: a JWT payload is like a sealed transparent envelope — you can see what's inside, but you can't change the contents without breaking the seal (the signature). It's a tamper-evident container, not a confidential one. For confidentiality, use JWE (JSON Web Encryption) — a different standard.
The auth server signs the JWT with its private key (for RS256) or a shared secret (for HS256). This is done at token issuance — once, when the user logs in:
RS256 (asymmetric, preferred for distributed systems):
GET https://auth.myapp.com/.well-known/jwks.jsonHS256 (symmetric):
In plain terms: RS256 is like a wax seal. The king (auth server) has the signet ring (private key). Anyone can verify the seal with a matching template (public key) without having the ring. HS256 is like a combination lock where every guard needs to know the combination — works fine for one guard, risky for many.
Tiny example: GitHub Actions uses RS256 JWTs for its OIDC tokens. GitHub's auth server signs tokens with its private key. AWS, Azure, and GCP can verify these tokens using GitHub's public JWKS endpoint — enabling passwordless authentication from CI/CD pipelines to cloud resources.
When any service receives a JWT, verification is a local, CPU-bound operation taking microseconds:
alg (e.g., RS256)exp — if current time > exp, reject with 401iss and aud — ensure the token was issued by the expected issuer for this servicesub, role, etc. — now trusted without any database lookupNo network call, no auth service dependency. This is why JWTs are the standard choice for stateless microservices.
What happens if someone modifies the payload? Say an attacker decodes the JWT, changes "role": "user" to "role": "admin", and re-encodes it. The header and payload change → the signature no longer matches → verification fails → 401. Without the private key, there's no way to produce a valid signature for a modified payload.
Concrete sketch: a user loads their dashboard. The browser sends a JWT (stored in a localStorage key). The API Gateway verifies the JWT signature in ~100 microseconds. No Redis, no auth database, no inter-service call. The API Gateway extracts userId, role, tenantId from the payload and attaches them to the request context. All downstream microservices receive pre-verified identity without doing any additional verification.
JWTs have several well-known vulnerabilities that arise from incorrect implementation:
The alg: none attack: the JWT spec originally allowed "alg": "none" — meaning no signature. An attacker creates a JWT with "alg": "none" and "role": "admin" in the payload. Vulnerable libraries that trusted the alg header and skipped signature verification would accept this as valid. Fix: always validate that the algorithm matches what you expect (RS256 or HS256); never accept none. Use a well-maintained JWT library that handles this by default.
RS256/HS256 confusion attack: some buggy libraries that support both RS256 and HS256 could be tricked: an attacker takes your RS256-signed token, changes the header to alg: HS256, and signs it with your public key (which is public, so anyone has it). If the library uses the public key as the HS256 secret for verification, it accepts the forged token. Fix: always explicitly specify the algorithm when verifying tokens; don't infer it from the token header.
Long expiry windows: a JWT with exp set to 30 days is valid for 30 days even if the user changes their password, is fired, or the token is stolen. There's no server-side state to invalidate. Fix: use short-lived access tokens (15 minutes) combined with refresh tokens. Refresh tokens live on the server (in a database) and can be revoked instantly.
Sensitive data in the payload: because the payload is just base64, not encrypted, any intermediary can read it. Don't put passwords, credit card numbers, or API secrets in JWT claims. If you need confidential claims, use JWE (JSON Web Encryption) — which actually encrypts the payload.
Tiny example: the 2018 Auth0 vulnerability: a critical bug in certain JWT libraries allowed alg: none JWTs to be accepted as valid, giving attackers admin access to any Auth0-protected API. Fixed immediately, but illustrates why using battle-tested JWT libraries (and keeping them updated) matters more than implementing your own.
| Property | Opaque Token | JWT |
|---|---|---|
| Auth service dependency | Yes — every request calls auth service | No — stateless local verification |
| Revocation | Instant — delete from store | Delayed — must wait for expiry (without blacklist) |
| Payload visibility | Server-side only | Anyone with the token can decode it |
| Token size | Small (random ID) | Larger (JSON payload, ~1–4 KB) |
| Cross-service use | Requires auth service calls | Any service verifies independently |
JWTs are everywhere — if you build or consume APIs, you will deal with them constantly. The practical checklist: (1) use RS256 over HS256 for any distributed system — you only need to protect one private key; (2) set exp to 15 minutes for access tokens; use refresh tokens for long-lived sessions; (3) validate iss, aud, and exp on every request — not just the signature; (4) never put sensitive data in the payload; (5) use a well-maintained JWT library (not your own implementation). Done right, JWTs dramatically simplify authentication in distributed systems.
Next: Role-Based Access Control (RBAC) — how to model what users are allowed to do using roles and permissions.
Opaque token vs JWT — an opaque token is a random ID requiring a DB lookup; a JWT carries all claims inside it — no lookup needed.