Skip to main content
โšก Calmops

JWT (JSON Web Tokens): A Complete Guide

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
{
  "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 time
  • nbf โ€” not before
  • iss โ€” issuer
  • aud โ€” 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

Comments