backend

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.

By Akash Sharma·6 min read
#jwt
#authentication
#security
#backend
#python
#golang
#api

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:

plaintext
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsImV4cCI6MTcxNTAwMDAwMH0.abc123signature

Three parts, separated by dots: header.payload.signature

Header

json
{
  "alg": "HS256",
  "typ": "JWT"
}

Specifies the signing algorithm.

Payload

json
{
  "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

plaintext
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

plaintext
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 needed

Signing 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.

python
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
python
# 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.

plaintext
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
python
# 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

go
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.

python
# 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

JWTSessions
Server stateStatelessStateful (session store)
ScalabilityWorks across serversNeeds shared session store
RevocationHard (need blocklist)Easy (delete from store)
Token sizeLarger (whole payload)Small (just session ID)
Best forAPIs, microservicesTraditional 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.