JWT Authentication: How It Works and Common Pitfalls
Learn how JSON Web Tokens work, when to use them, and the security mistakes developers make. Covers signing algorithms, refresh tokens, and Python/Go examples.
You call an API. It needs to know who you are. But HTTP has no memory — every request starts fresh. How does the server know you're logged in?
JWTs are one of the most common answers.
What Is a JWT?
A JWT (JSON Web Token) is a compact, self-contained token you pass with every request. The server doesn't need to look you up in a database — it reads the token, verifies the signature, and knows who you are.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsImV4cCI6MTcxNTAwMDAwMH0.abc123signatureThree parts, separated by dots: header.payload.signature
Header
{
"alg": "HS256",
"typ": "JWT"
}Specifies the signing algorithm.
Payload
{
"user_id": 123,
"email": "alice@example.com",
"role": "admin",
"exp": 1715000000,
"iat": 1714996400
}The actual data. exp is expiry (Unix timestamp). iat is issued-at. Everything here is readable by anyone — it's just base64 encoded, not encrypted.
Signature
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret_key
)This is what makes it secure. Without the secret key, you can't forge a valid signature.
How It Works in Practice
1. User logs in (username + password)
2. Server verifies credentials
3. Server creates JWT, signs it with secret key, sends it back
4. Client stores token, sends it in every request:
Authorization: Bearer <token>
5. Server verifies signature on each request — no DB lookup neededSigning Algorithms: HS256 vs RS256
HS256 (HMAC SHA-256): Uses one secret key for both signing and verification. Simple. Use when only one service signs and verifies tokens (monolith or single backend).
RS256 (RSA SHA-256): Uses a private key to sign, a public key to verify. Any service can verify tokens without knowing the signing secret. Use in microservices — your auth service holds the private key, every other service gets the public key.
import jwt
from datetime import datetime, timedelta
SECRET_KEY = "your-secret-key"
# HS256: sign and verify with same key
def create_token(user_id: int) -> str:
payload = {
"user_id": user_id,
"exp": datetime.utcnow() + timedelta(hours=1),
"iat": datetime.utcnow()
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
def verify_token(token: str) -> dict:
return jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
# Usage
token = create_token(123)
data = verify_token(token)
print(data["user_id"]) # 123# RS256: sign with private key, verify with public key
from cryptography.hazmat.primitives import serialization
with open("private_key.pem", "rb") as f:
private_key = serialization.load_pem_private_key(f.read(), password=None)
with open("public_key.pem", "rb") as f:
public_key = serialization.load_pem_public_key(f.read())
# Auth service: sign
token = jwt.encode(payload, private_key, algorithm="RS256")
# Any other service: verify (only needs public key)
data = jwt.decode(token, public_key, algorithms=["RS256"])Access Tokens and Refresh Tokens
Short-lived access tokens are the norm. But 15-minute expiry means users log out constantly. Solution: two tokens.
Access token: Short-lived (15 min to 1 hour). Used for every API call. Stored in memory.
Refresh token: Long-lived (7–30 days). Used only to get a new access token. Stored in an httpOnly cookie.
Login → access_token (15m) + refresh_token (7d)
On each request:
→ Send access_token in Authorization header
→ If 401: send refresh_token to /auth/refresh
→ Get new access_token
→ Retry original request
On logout:
→ Delete access_token from memory
→ Call /auth/logout to invalidate refresh_token in DB# FastAPI example
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
app = FastAPI()
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
token = credentials.credentials
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return payload["user_id"]
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@app.get("/profile")
def get_profile(user_id: int = Depends(get_current_user)):
return {"user_id": user_id}Go Example
package main
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secretKey = []byte("your-secret-key")
type Claims struct {
UserID int `json:"user_id"`
jwt.RegisteredClaims
}
func CreateToken(userID int) (string, error) {
claims := Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secretKey)
}
func VerifyToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
return secretKey, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}Common Security Mistakes
1. Storing tokens in localStorage
localStorage is accessible to any JavaScript on the page. One XSS vulnerability and your tokens are stolen.
Better: Store access tokens in memory (a JS variable), refresh tokens in httpOnly cookies (can't be read by JS).
2. Never expiring tokens
A stolen token that never expires is usable forever. Always set exp. Short access tokens + refresh tokens is the right pattern.
3. Algorithm confusion attack
If your server accepts both HS256 and RS256, an attacker can take your RS256 public key, use it as a secret to sign an HS256 token, and your server might accept it.
Fix: always specify exact algorithms when verifying.
# Wrong — accepts anything
jwt.decode(token, key, algorithms=["HS256", "RS256"])
# Right — lock it down
jwt.decode(token, key, algorithms=["RS256"])4. Putting sensitive data in the payload
Payloads are base64 decoded, not encrypted. Anyone who has the token can read it. Never put passwords, SSNs, or private data in the payload.
5. Not revoking tokens on logout
JWTs are stateless — once issued, they're valid until expiry. If someone steals a token after logout, they can still use it.
Solutions:
- Keep a blocklist in Redis (check on every request)
- Short access token TTL (if stolen, it expires in 15 min)
- Rotate refresh tokens on each use (refresh token rotation)
JWT vs Sessions
| JWT | Sessions | |
|---|---|---|
| Server state | Stateless | Stateful (session store) |
| Scalability | Works across servers | Needs shared session store |
| Revocation | Hard (need blocklist) | Easy (delete from store) |
| Token size | Larger (whole payload) | Small (just session ID) |
| Best for | APIs, microservices | Traditional web apps |
Neither is universally better. Sessions are simpler for single-server web apps. JWTs work better when you have multiple services or need to share auth across domains.
Key Takeaways
- JWTs are self-contained tokens — no DB lookup needed to verify
- Use HS256 for single-server; RS256 for microservices (verify without sharing the secret)
- Short-lived access tokens + long-lived refresh tokens is the right pattern
- Store access tokens in memory, refresh tokens in httpOnly cookies
- Never store sensitive data in the payload — it's not encrypted
- Short expiry is your best defense against token theft
JWTs solve a real problem, but they shift complexity to the client. Understand the tradeoffs before committing.
Related reading: REST API Design Best Practices · Rate Limiting Your API
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
Rate Limiting: Protect Your API From Being Overwhelmed
Learn how to implement rate limiting with Redis. Covers token bucket, fixed window, and sliding window algorithms with Python and Go examples.
REST API Design Best Practices That Actually Matter
Learn REST API design best practices: URL structure, HTTP methods, status codes, versioning, and error handling. Build APIs developers love to use.
Redis Caching Explained: Speed Up Your Backend
Learn how Redis caching works, when to use it, and common patterns like cache-aside, write-through, and TTL. With Python and Go examples.