Introduction
JSON Web Tokens (JWT) are an open standard (RFC 7519) for securely transmitting information between parties as a JSON object. They’re widely used for authentication and authorization in web APIs, replacing traditional session IDs in stateless architectures.
JWT vs Session ID
The fundamental difference is by value vs by reference:
| JWT | Session ID | |
|---|---|---|
| Storage | Client-side (token contains data) | Server-side (ID references server data) |
| Scalability | Stateless โ no server storage needed | Requires shared session store (Redis, DB) |
| Revocation | Difficult (token valid until expiry) | Easy (delete from store) |
| Size | Larger (~200-500 bytes) | Small (~32 bytes) |
| Data | Claims embedded in token | Data on server |
JWT is “by value” โ the token itself contains the user’s claims. A session ID is “by reference” โ it’s just a pointer to data stored on the server.
JWT Structure
A JWT consists of three Base64URL-encoded parts separated by dots:
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcwMDAwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header
{
"alg": "HS256",
"typ": "JWT"
}
Specifies the signing algorithm (HS256, RS256, ES256, etc.) and token type.
Payload (Claims)
{
"sub": "1234567890",
"user_id": 42,
"role": "admin",
"iat": 1700000000,
"exp": 1700086400
}
Standard claims:
subโ subject (user identifier)iatโ issued at (Unix timestamp)expโ expiration timenbfโ not beforeissโ issueraudโ audience
Important: JWT is encoded, not encrypted. The payload is Base64URL-encoded and readable by anyone. Never store sensitive data (passwords, credit cards) in a JWT unless you use JWE (JSON Web Encryption).
Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The signature verifies the token hasn’t been tampered with. Without the secret key, you can’t forge a valid signature.
Signing Algorithms
HMAC (Symmetric) โ HS256, HS384, HS512
Uses a single shared secret. Both signing and verification use the same key:
Signing: HMAC(header.payload, secret)
Verifying: HMAC(header.payload, secret) == signature
Best for: Single-service authentication where the same service signs and verifies.
RSA/ECDSA (Asymmetric) โ RS256, ES256
Uses a private key to sign and a public key to verify:
Signing: RSA_SIGN(header.payload, private_key)
Verifying: RSA_VERIFY(header.payload, signature, public_key)
Best for: Microservices where multiple services need to verify tokens but only one service issues them. Services only need the public key โ they can’t forge tokens.
Implementation
Ruby (ruby-jwt gem)
gem 'jwt'
require 'jwt'
SECRET_KEY = ENV['JWT_SECRET'] # keep this secret!
# Generate a token
def generate_token(user_id, role)
payload = {
sub: user_id,
role: role,
iat: Time.now.to_i,
exp: Time.now.to_i + 24 * 3600 # expires in 24 hours
}
JWT.encode(payload, SECRET_KEY, 'HS256')
end
# Verify and decode a token
def decode_token(token)
decoded = JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')
decoded[0] # returns the payload hash
rescue JWT::ExpiredSignature
raise "Token has expired"
rescue JWT::DecodeError => e
raise "Invalid token: #{e.message}"
end
# Usage
token = generate_token(42, "admin")
puts token
payload = decode_token(token)
puts payload['sub'] # => 42
puts payload['role'] # => "admin"
Rails with Knock gem
Knock wraps ruby-jwt for Rails:
# Gemfile
gem 'knock'
# config/initializers/knock.rb
Knock.setup do |config|
config.token_lifetime = 24.hours
config.token_secret_signature_key = -> { ENV['JWT_SECRET'] }
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include Knock::Authenticable
end
# app/controllers/user_token_controller.rb
class UserTokenController < ApplicationController
def create
# POST /user_token with { auth: { email: ..., password: ... } }
# Returns { jwt: "..." }
end
end
# Protect an endpoint
class ArticlesController < ApplicationController
before_action :authenticate_user
def index
render json: Article.all
end
end
Go (golang-jwt)
import (
"github.com/golang-jwt/jwt/v5"
"time"
)
var secretKey = []byte(os.Getenv("JWT_SECRET"))
type Claims struct {
UserID int `json:"user_id"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// Generate token
func GenerateToken(userID int, role string) (string, error) {
claims := Claims{
UserID: userID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: strconv.Itoa(userID),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secretKey)
}
// Verify token
func VerifyToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
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
}
Authentication Flow
1. User POSTs credentials โ POST /auth/login { email, password }
2. Server validates credentials
3. Server generates JWT with user_id, role, expiry
4. Server returns JWT to client
5. Client stores JWT (localStorage or httpOnly cookie)
6. Client sends JWT in Authorization header on every request:
Authorization: Bearer eyJhbGci...
7. Server validates JWT signature and expiry
8. Server extracts user_id from payload โ no database lookup needed
Security Best Practices
Use Short Expiry Times
# Access token: short-lived (15 minutes)
access_token_payload = {
sub: user.id,
exp: Time.now.to_i + 15 * 60
}
# Refresh token: longer-lived (7 days), stored securely
refresh_token_payload = {
sub: user.id,
type: "refresh",
exp: Time.now.to_i + 7 * 24 * 3600
}
Store in httpOnly Cookies, Not localStorage
// BAD: localStorage is accessible to JavaScript (XSS risk)
localStorage.setItem('token', jwt);
// GOOD: httpOnly cookie โ not accessible to JavaScript
// Set by server:
// Set-Cookie: token=...; HttpOnly; Secure; SameSite=Strict; Path=/
Validate the Algorithm
# Always specify the expected algorithm โ prevents algorithm confusion attacks
JWT.decode(token, secret, true, algorithms: ['HS256'])
# NOT: JWT.decode(token, secret, true) # accepts any algorithm!
Never Store Sensitive Data in Payload
# BAD: sensitive data in payload (readable by anyone)
payload = { user_id: 42, password: "secret", credit_card: "4111..." }
# GOOD: only non-sensitive identifiers
payload = { user_id: 42, role: "user", exp: ... }
Implement Token Revocation
JWT tokens are valid until expiry โ you can’t “log out” a token. Solutions:
# Option 1: Short expiry + refresh tokens
# Option 2: Token blacklist (Redis)
class TokenBlacklist
def self.revoke(jti)
Redis.current.setex("revoked:#{jti}", TOKEN_EXPIRY, "1")
end
def self.revoked?(jti)
Redis.current.exists("revoked:#{jti}")
end
end
# Add jti (JWT ID) to payload
payload = { sub: user.id, jti: SecureRandom.uuid, exp: ... }
# Check on each request
def authenticate!
payload = decode_token(request.headers['Authorization'])
raise "Token revoked" if TokenBlacklist.revoked?(payload['jti'])
end
JWT vs Alternatives
| JWT | Opaque Token | Session Cookie | |
|---|---|---|---|
| Stateless | Yes | No | No |
| Revocable | Hard | Easy | Easy |
| Microservice-friendly | Yes | Requires introspection | No |
| Size | Medium | Small | Small |
| Self-contained | Yes | No | No |
Resources
- JWT.io โ Debugger and Documentation
- RFC 7519 โ JSON Web Token
- ruby-jwt gem
- golang-jwt
- Knock for Rails
- OWASP JWT Security Cheat Sheet
Comments