13.7. JSON Web Tokens
Table of Contents
Why JSON Web Tokens Matter
JSON Web Tokens, usually written as JWTs and pronounced "jots", are a very popular way to implement token based authentication in backend systems.
They are used in many modern web and mobile applications, especially when you have:
- A REST API that serves JavaScript frontends or mobile apps
- Multiple services that must share authentication information
- Stateless authentication, where the server does not keep session data in memory
In this chapter you will learn what JWTs are, what problem they solve, and how to use them correctly without going deep into topics that belong in separate chapters such as Access Tokens, Refresh Tokens, or OAuth 2.0.
What Is a JWT?
A JSON Web Token is a compact string that encodes some JSON data and is cryptographically signed.
A JWT is made of three parts, separated by dots:
header.payload.signatureFor example:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiIxMjM0NTYiLCJ1c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzAwMDAwMDAwfQ
.
X4rnRBqcYqtwbTgBjHqQ5V9RzK0PwtH0RmL4i3Pzga0You usually see JWTs in HTTP headers:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...The Three Parts of a JWT
1. Header
The header tells:
- Which algorithm is used to sign the token
- The type of the token (usually
"JWT")
Example header JSON:
{
"alg": "HS256",
"typ": "JWT"
}This JSON is then Base64URL encoded to a string like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ92. Payload
The payload contains the claims. A claim is a piece of information, such as:
- Who the user is
- What roles or permissions they have
- When the token expires
Example payload JSON:
{
"sub": "123456", // subject, usually user id
"username": "alice",
"role": "admin",
"exp": 1700000000
}This JSON is also Base64URL encoded to form the second part of the token.
3. Signature
The signature makes the token tamper evident. The server signs the header and payload with a secret key.
For an HMAC algorithm like HS256, the signature is:
$$
\text{signature} = \text{HMAC\_SHA256}(\text{base64url(header)} + "." + \text{base64url(payload)}, \text{secret})
$$
The result is Base64URL encoded to become the third part of the token.
A JWT is not encrypted by default. Anyone who has the token can read its header and payload.
The signature does not hide information, it only protects integrity.
Never put passwords or other highly sensitive secrets into a JWT payload.
How JWT Authentication Works
JWTs are often used for stateless authentication. That means the server does not keep login state in memory or in a session store for each user. Instead, the client sends a JWT with every request and the server verifies it.
Typical Login Flow with JWT
- User logs in
- Client sends username and password to the backend (for example with a POST
/loginrequest). - Backend verifies the password (using secure password hashing from other chapters, not plain text).
- Server creates a token
- Backend creates a JWT that includes the user's id and maybe role or other claims.
- Backend signs the JWT with a secret or private key.
- Backend returns the JWT to the client.
Example JSON response:
{
"access_token": "<jwt-string-here>",
"token_type": "bearer"
}- Client stores the token
The client can store it:
- In memory (JavaScript variable, React state)
- In
localStorageorsessionStorage(with XSS risks) - In an HTTP-only cookie (similar to session cookies, but still a token)
- Client uses the token
For every request to a protected endpoint, the client sends:
GET /api/me HTTP/1.1
Host: api.example.com
Authorization: Bearer <jwt-string-here>- Server verifies the token
- Backend reads the Authorization header.
- Backend verifies the signature using its secret or public key.
- Backend checks if the token is expired.
- If valid, backend trusts the claims inside the token (such as
sub,role). - Server executes the request
- Backend executes business logic using the user identity from the token.
- Backend returns a response.
This flow allows the backend to not store session state for each user, which helps scalability and works well with multiple servers behind a load balancer.
Standard JWT Claims
JWT defines some common claim names. You can use custom names too, but these are typical:
| Claim | Meaning | Example value |
|---|---|---|
iss | Issuer, who created the token | "https://api.example.com" |
sub | Subject, who the token refers to | "user-123" |
aud | Audience, who the token is for | "my-frontend-app" |
exp | Expiration time, in seconds (UTC) | 1700000000 |
nbf | Not before, token valid from this time | 1699990000 |
iat | Issued at, creation time | 1699999000 |
jti | JWT ID, unique identifier | "a9f9cba6-..." |
In many simple systems you mainly use:
subfor user idexpfor expiration time- Maybe
role,is_admin, orscopesas custom claims
Example payload with typical claims:
{
"sub": "123",
"username": "alice",
"role": "admin",
"exp": 1700000000,
"iat": 1699999000,
"iss": "https://api.example.com"
}
Always include an expiration (exp) in your JWTs.
A token that never expires is very dangerous. If it is stolen, an attacker can use it forever.
Symmetric vs Asymmetric Signing
How you sign a JWT affects how your system is designed.
Symmetric Signing (HS256, HS384, HS512)
With symmetric signing, one shared secret key is used to:
- Sign tokens
- Verify tokens
Example algorithms:
HS256HS384HS512
This is simple and common in single service backends.
Example pseudocode:
jwt.encode(payload, secret_key, algorithm="HS256")
jwt.decode(token, secret_key, algorithms=["HS256"])Pros
- Simple to implement
- One secret that you keep safe
- Fast enough for most use cases
Cons
- Every service that verifies tokens must know the secret
- If the secret leaks, anybody can create valid tokens
Asymmetric Signing (RS256, ES256)
With asymmetric signing, there are two keys:
- Private key to sign tokens
- Public key to verify tokens
Example algorithms:
RS256(RSA + SHA-256)ES256(Elliptic Curve)
Example pseudocode:
jwt.encode(payload, private_key, algorithm="RS256")
jwt.decode(token, public_key, algorithms=["RS256"])Pros
- Only the authentication service needs the private key
- Other services only need the public key
- If a microservice is compromised, it cannot sign new tokens
Cons
- More complex key management
- Slightly slower than HS256 (usually still fine)
For learning and small projects, HS256 is often enough. For larger systems or multiple services, RS256 or another asymmetric algorithm is often preferred.
Example: Creating and Verifying JWTs
In practice you use a library rather than manually building the Base64 strings.
Below is an example using Python with the popular PyJWT library. It is not a complete app, only a clear demonstration.
Creating a JWT
import jwt
from datetime import datetime, timedelta, timezone
SECRET_KEY = "super-secret-key-change-me"
def create_access_token(user_id: str, username: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"username": username,
"exp": now + timedelta(minutes=15),
"iat": now,
"iss": "https://api.example.com"
}
token = jwt.encode(
payload,
SECRET_KEY,
algorithm="HS256"
)
return tokenHere:
subidentifies the userusernameis extra infoexpis 15 minutes in the futureiatis the current timeissis the API base URL
Verifying a JWT
import jwt
from jwt import InvalidTokenError, ExpiredSignatureError
def verify_access_token(token: str) -> dict:
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"],
issuer="https://api.example.com"
)
return payload
except ExpiredSignatureError:
raise ValueError("Token has expired")
except InvalidTokenError:
raise ValueError("Token is invalid")In a real backend endpoint, you would:
- Read the
Authorizationheader. - Extract the token after
Bearer. - Call
verify_access_token. - Attach the user information from the payload to the request context.
Where JWTs Are Stored and Sent
JWTs are only useful if the client sends them correctly.
Common Ways to Send a JWT
| Location | Example | Notes |
|---|---|---|
| HTTP header | Authorization: Bearer <token> | Most common for APIs |
| Cookie | Set-Cookie: access_token=<token>; HttpOnly | Useful for browsers |
| Query string | /some-route?token=<token> | Not recommended |
Using the Authorization header is usually the easiest for API clients and mobile apps.
For browser based apps, using HTTP-only cookies can help protect against some attacks, but has its own details which are covered in other chapters.
Do not put tokens in URLs (query parameters) if you can avoid it.
Tokens in URLs can end up:
- In server logs
- In browser history
- In referrer headers to other sites
JWT Best Practices
Using JWTs correctly is important. Many security problems come from incorrect usage, not from the JWT standard itself.
1. Use Short Expiration Times
Use relatively short lifetimes for access tokens, for example:
- 5 to 15 minutes
If a token is stolen, a short lifetime reduces the damage.
You will later combine this with refresh tokens in the dedicated chapter.
2. Validate Algorithm and Claims
When you decode a JWT:
- Always specify allowed algorithms explicitly
- Check
exp,iss,aud, and any other important claims
Pseudocode:
payload = jwt.decode(
token,
key,
algorithms=["HS256"],
issuer="https://api.example.com",
audience="my-frontend-app"
)
Never allow the token to decide the algorithm by itself.
Always pass the accepted algorithms explicitly to the library. This protects against some known attacks.
3. Do Not Store Sensitive Data in the Payload
JWT payloads are just Base64URL encoded. Any attacker who gets the token can read it.
Avoid storing:
- Passwords
- Secret keys
- Payment card numbers
- Any data that must remain confidential
Use JWTs to store identifiers, roles, and flags, not secrets.
4. Rotate Secrets and Keys
For HS256:
- Store the secret in a secure place, such as an environment variable or a secret manager.
- Change it periodically.
For RS256:
- Rotate private / public key pairs on a schedule.
- Keep old keys around for some time to validate existing tokens.
Key and secret management is covered in depth in the security and secrets management chapters, but it is important to remember that JWT security depends directly on key security.
5. Consider Token Revocation
JWTs are stateless by default. Once a token is issued, the server cannot "take it back" without extra logic.
You might need revocation when:
- A user logs out from all devices
- A user account is disabled
- A token is known to be stolen
Common approaches:
- Maintain a token blacklist in a database or Redis and check it for every request.
- Use a
jti(JWT ID) claim and store revokedjtivalues. - Use very short lived access tokens and rely on refresh tokens with server side checks.
Token revocation in detail is usually handled together with access and refresh tokens, which are covered in other chapters.
Comparing JWTs with Server Sessions
JWTs and server side sessions are both ways to keep users authenticated.
| Feature | JWT | Server session |
|---|---|---|
| Storage location | Client stores token | Server stores session data |
| Server memory use | None for tokens | Grows with number of sessions |
| Stateless | Yes (usually) | No |
| Revocation | Harder, needs extra logic | Easier, delete session |
| Scaling across servers | Easy, any server can verify | Need shared session store |
| Token size | Usually larger | Session id is small |
JWTs are very useful when:
- You have stateless REST APIs
- You have multiple backend servers
- You have multiple clients (web, mobile, other services)
Session based authentication can still be a good choice, especially for simple server rendered web apps.
Simple End-to-End Example
To see everything together, imagine this simplified API.
Login Endpoint
POST /login HTTP/1.1
Content-Type: application/json
{
"username": "alice",
"password": "secret-password"
}Simplified backend logic:
def login(username: str, password: str):
user = find_user_in_db(username)
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(
user_id=str(user.id),
username=user.username
)
return {"access_token": token, "token_type": "bearer"}The client receives:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer"
}Protected Endpoint
Client request:
GET /me HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...Backend logic to protect the endpoint:
def get_current_user(authorization_header: str):
if not authorization_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
token = authorization_header.split(" ", 1)[1]
try:
payload = verify_access_token(token)
except ValueError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
user_id = payload["sub"]
user = find_user_in_db_by_id(user_id)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
The /me endpoint can then use get_current_user:
def me_endpoint(request):
user = get_current_user(request.headers.get("Authorization", ""))
return {
"id": user.id,
"username": user.username
}This shows the complete minimal flow:
- User logs in.
- Server issues JWT.
- Client sends JWT on each request.
- Server verifies token and returns data.
Summary
- A JWT is a string that contains three parts: header, payload, and signature.
- JWTs are signed, not encrypted by default.
- JWTs are very useful for stateless authentication, especially in APIs.
- Clients typically send JWTs in the
Authorization: Bearerheader. - Always use
expfor expiration and validate important claims. - Protect your signing keys and secrets.
- Do not store sensitive data like passwords in a JWT.
In the next related chapters, you will see how JWTs are used as access tokens, how refresh tokens work together with JWTs, and how these tokens integrate into complete authentication flows.
Views: 4
KAHIBARO