Overview
JSON Web Tokens (JWT) provide a compact, URL-safe method for representing claims between two parties. A JWT encodes JSON data and signs it cryptographically, allowing the recipient to verify the token's authenticity and integrity without maintaining server-side session state.
The token format consists of three Base64URL-encoded sections separated by periods: header, payload, and signature. The header specifies the signing algorithm, the payload contains claims (statements about an entity), and the signature verifies that the token hasn't been tampered with.
# A typical JWT structure
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
JWTs originated from the need for stateless authentication in distributed systems. Traditional session-based authentication requires server-side storage and lookup for each request. JWTs eliminate this requirement by embedding all necessary information within the token itself. The server validates the token's signature rather than querying a database.
This approach fits well with RESTful API design principles, microservices architectures, and single-page applications where maintaining session state across multiple services becomes complex. Mobile applications particularly benefit from JWTs because they avoid cookie-based authentication mechanisms that don't translate well to native mobile environments.
JWTs serve multiple purposes beyond authentication. They function as information exchange mechanisms where the signature guarantees data integrity. They enable single sign-on (SSO) across multiple domains by providing a portable identity credential. They support authorization by embedding permission claims directly in the token.
Key Principles
A JWT consists of three components encoded as Base64URL strings and concatenated with periods. The header contains metadata about the token type and signing algorithm. The payload contains claims, which are statements about the entity and additional metadata. The signature provides cryptographic verification of the token's authenticity.
The header typically specifies two fields: typ indicating the token type (JWT) and alg specifying the signing algorithm. Supported algorithms include HMAC with SHA-256 (HS256), RSA with SHA-256 (RS256), and Elliptic Curve Digital Signature Algorithm (ECDSA). The choice of algorithm determines the signature generation and verification process.
# Header example (before Base64URL encoding)
{
"alg": "HS256",
"typ": "JWT"
}
The payload contains claims, divided into three categories. Registered claims are predefined standard fields like iss (issuer), sub (subject), aud (audience), exp (expiration time), nbf (not before), and iat (issued at). Public claims are custom fields defined in the IANA JWT Claims Registry or using collision-resistant names. Private claims are custom fields agreed upon between parties.
# Payload example (before Base64URL encoding)
{
"sub": "user123",
"name": "Jane Developer",
"role": "admin",
"exp": 1735689600,
"iat": 1735603200
}
The signature creation process involves concatenating the encoded header and payload with a period, then applying the specified algorithm with a secret key. For HMAC algorithms, the same secret key signs and verifies the token. For RSA and ECDSA algorithms, a private key signs the token and the corresponding public key verifies it.
signature = HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
JWTs operate on a stateless verification model. The server doesn't maintain a record of issued tokens. Instead, it verifies each token's signature using the known secret or public key. If the signature validates and the claims satisfy the application's requirements (expiration, audience, etc.), the server accepts the token.
This stateless nature creates both advantages and constraints. The server scales horizontally without shared session storage requirements. Token validation remains fast because it involves only cryptographic operations, not database queries. However, the server cannot invalidate individual tokens before their expiration time without implementing additional infrastructure like a token blacklist.
The Base64URL encoding differs from standard Base64 by replacing + with -, / with _, and removing padding = characters. This modification makes tokens URL-safe and suitable for use in HTTP headers, URL parameters, and HTML form values without requiring additional encoding.
Claims-based authentication shifts the security model from "who has access" to "what the token proves." The token becomes a portable proof of identity and permissions. The application examines token claims to make authorization decisions without querying a user database on every request.
Ruby Implementation
Ruby's jwt gem provides a straightforward interface for encoding and decoding JWTs. The gem supports multiple signing algorithms including HMAC, RSA, and ECDSA variants.
require 'jwt'
# Encoding a token with HS256 algorithm
payload = {
user_id: 123,
username: 'alice',
role: 'editor',
exp: Time.now.to_i + 3600 # Expires in 1 hour
}
secret = 'your-256-bit-secret'
token = JWT.encode(payload, secret, 'HS256')
# => "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiYWxpY2UiLCJyb2xlIjoiZWRpdG9yIiwiZXhwIjoxNzM1NjA2ODAwfQ.qL9hVVNKhQPXu8e-vk8..."
Decoding validates the signature and returns the payload. The decode method raises exceptions for invalid tokens, allowing the application to handle verification failures appropriately.
begin
decoded = JWT.decode(token, secret, true, { algorithm: 'HS256' })
payload = decoded[0]
header = decoded[1]
puts payload['user_id'] # => 123
puts payload['role'] # => "editor"
rescue JWT::DecodeError => e
puts "Invalid token: #{e.message}"
end
The third parameter (true) enables signature verification. Setting it to false skips verification, which should only occur in specific scenarios like inspecting token contents without validation. The fourth parameter specifies verification options including expected algorithm, required claims, and custom validators.
RSA-based signing uses asymmetric cryptography where a private key signs tokens and public keys verify them. This approach suits distributed systems where multiple services need to verify tokens but shouldn't have the ability to create them.
require 'openssl'
# Generate RSA key pair
private_key = OpenSSL::PKey::RSA.generate(2048)
public_key = private_key.public_key
# Encode with private key
payload = { user_id: 456, exp: Time.now.to_i + 3600 }
token = JWT.encode(payload, private_key, 'RS256')
# Decode with public key
decoded = JWT.decode(token, public_key, true, { algorithm: 'RS256' })
# => [{"user_id"=>456, "exp"=>1735606800}, {"alg"=>"RS256"}]
For production systems, store the private key securely in environment variables or key management systems rather than hardcoding it. The public key can be distributed more freely since it only verifies signatures.
# Loading keys from environment
private_key = OpenSSL::PKey::RSA.new(ENV['JWT_PRIVATE_KEY'])
public_key = OpenSSL::PKey::RSA.new(ENV['JWT_PUBLIC_KEY'])
The jwt gem supports custom claim validators through the verify_claims method or by passing verification options. Common validations include expiration time, issued-at time, not-before time, issuer, and audience.
# Comprehensive token verification
options = {
algorithm: 'HS256',
verify_expiration: true,
verify_iat: true,
exp_leeway: 30, # 30 second clock skew tolerance
iss: 'auth.example.com',
verify_iss: true,
aud: 'api.example.com',
verify_aud: true
}
begin
decoded = JWT.decode(token, secret, true, options)
rescue JWT::ExpiredSignature
# Handle expired token
rescue JWT::InvalidIssuerError
# Handle wrong issuer
rescue JWT::InvalidAudError
# Handle wrong audience
end
Rails applications commonly implement JWT authentication in a dedicated service class or concern. This encapsulation provides a clean interface for token operations throughout the application.
class JwtService
SECRET = Rails.application.credentials.jwt_secret
ALGORITHM = 'HS256'
def self.encode(payload, exp: 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET, ALGORITHM)
end
def self.decode(token)
body = JWT.decode(token, SECRET, true, { algorithm: ALGORITHM })[0]
HashWithIndifferentAccess.new(body)
rescue JWT::DecodeError => e
raise AuthenticationError, e.message
end
end
# Usage in controller
class Api::AuthenticationController < ApplicationController
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JwtService.encode(user_id: user.id, role: user.role)
render json: { token: token, user: user }
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
end
Middleware or before_action filters extract and validate tokens from incoming requests. The typical pattern retrieves the token from the Authorization header, validates it, and loads the associated user.
class ApplicationController < ActionController::API
before_action :authenticate_request
attr_reader :current_user
private
def authenticate_request
header = request.headers['Authorization']
token = header.split(' ').last if header
decoded = JwtService.decode(token)
@current_user = User.find(decoded[:user_id])
rescue ActiveRecord::RecordNotFound, AuthenticationError
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
Security Implications
JWTs face multiple security vulnerabilities when implemented incorrectly. The most critical issues involve algorithm confusion, weak secrets, information disclosure, and insufficient validation.
Algorithm confusion attacks exploit the flexibility in the JWT header's algorithm field. An attacker modifies the algorithm from RS256 (RSA) to HS256 (HMAC), then signs the token using the public key as the HMAC secret. If the server doesn't enforce expected algorithms, it accepts the forged token.
# Vulnerable code - accepts any algorithm
decoded = JWT.decode(token, public_key, true)
# Secure code - explicitly requires RS256
decoded = JWT.decode(token, public_key, true, { algorithm: 'RS256' })
The "none" algorithm represents another confusion vector. Some JWT libraries accept tokens with alg: none, bypassing signature verification entirely. Attackers set the algorithm to "none" and remove the signature, creating an unsigned token that vulnerable implementations accept.
# Explicitly whitelist allowed algorithms
ALLOWED_ALGORITHMS = ['RS256'].freeze
def decode_token(token)
JWT.decode(token, public_key, true, { algorithm: ALLOWED_ALGORITHMS })
rescue JWT::IncorrectAlgorithm
raise SecurityError, 'Invalid signing algorithm'
end
Weak secrets in HMAC-signed tokens enable brute force attacks. Short or predictable secrets allow attackers to discover the secret through dictionary attacks or exhaustive search. The secret must contain sufficient entropy to resist brute force attempts.
# Weak secret - easily guessable
secret = 'secret123'
# Strong secret - 256 bits of entropy
secret = SecureRandom.hex(32) # => 64 character hex string
# Or use Rails credentials
secret = Rails.application.credentials.jwt_secret
JWTs carry their payload in plain text, merely Base64URL-encoded. Anyone with access to the token can decode and read its contents without knowing the secret. Store only non-sensitive information in JWT payloads. Never include passwords, credit card numbers, or other confidential data.
# Bad - sensitive data in token
payload = {
user_id: 123,
password_hash: '$2a$12$...', # Never include password hashes
ssn: '123-45-6789' # Never include sensitive personal data
}
# Good - only identification and claims
payload = {
user_id: 123,
role: 'admin',
permissions: ['read', 'write']
}
Token expiration mitigates the risk of stolen tokens. Short-lived tokens reduce the window of opportunity for attackers. The standard practice combines short-lived access tokens with longer-lived refresh tokens. The application uses refresh tokens to obtain new access tokens without requiring reauthentication.
class TokenService
ACCESS_TOKEN_EXPIRY = 15.minutes
REFRESH_TOKEN_EXPIRY = 7.days
def self.generate_tokens(user)
access_payload = {
user_id: user.id,
role: user.role,
type: 'access',
exp: ACCESS_TOKEN_EXPIRY.from_now.to_i
}
refresh_payload = {
user_id: user.id,
type: 'refresh',
jti: SecureRandom.uuid, # Unique token ID
exp: REFRESH_TOKEN_EXPIRY.from_now.to_i
}
{
access_token: JWT.encode(access_payload, SECRET, 'HS256'),
refresh_token: JWT.encode(refresh_payload, SECRET, 'HS256')
}
end
end
Refresh tokens require additional security measures. Store refresh token identifiers (jti claim) in a database with revocation capability. When a user logs out or security events occur, mark their refresh tokens as revoked.
Cross-site request forgery (CSRF) protection requires careful consideration with JWTs. Storing tokens in localStorage makes them immune to CSRF but vulnerable to XSS attacks. Storing tokens in httpOnly cookies protects against XSS but requires CSRF tokens. The choice depends on the application's threat model.
# Using httpOnly cookies with CSRF protection
class SessionsController < ApplicationController
def create
user = authenticate_user(params)
token = JwtService.encode(user_id: user.id)
cookies.encrypted[:jwt] = {
value: token,
httponly: true,
secure: Rails.env.production?,
same_site: :strict
}
render json: { user: user }
end
end
Token storage location affects security posture. localStorage provides easy access but exposes tokens to XSS attacks. sessionStorage offers the same accessibility with browser-tab-scoped lifetime. httpOnly cookies prevent JavaScript access but require CSRF protection. Each approach trades convenience for security.
Man-in-the-middle attacks intercept tokens during transmission. HTTPS encrypts all communication, preventing token interception. Never transmit JWTs over unencrypted HTTP connections in production environments.
Audience and issuer validation prevent token misuse across different services. The issuer claim identifies who created the token. The audience claim specifies intended recipients. Validate both to ensure tokens aren't used in unintended contexts.
def validate_token(token)
options = {
algorithm: 'RS256',
iss: 'https://auth.example.com',
verify_iss: true,
aud: 'https://api.example.com',
verify_aud: true
}
JWT.decode(token, public_key, true, options)
rescue JWT::InvalidIssuerError
raise SecurityError, 'Token from untrusted issuer'
rescue JWT::InvalidAudError
raise SecurityError, 'Token not intended for this service'
end
Practical Examples
A typical authentication flow starts with user login, token generation, and subsequent authenticated requests using the token.
# User registration and login
class AuthenticationController < ApplicationController
skip_before_action :authenticate_request, only: [:login, :register]
def register
user = User.new(user_params)
if user.save
tokens = TokenService.generate_tokens(user)
render json: { user: UserSerializer.new(user), **tokens }, status: :created
else
render json: { errors: user.errors }, status: :unprocessable_entity
end
end
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
tokens = TokenService.generate_tokens(user)
render json: { user: UserSerializer.new(user), **tokens }
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
private
def user_params
params.require(:user).permit(:email, :password, :password_confirmation, :name)
end
end
Client applications store the token and include it in subsequent requests. The server validates the token and extracts user information from the claims.
# Making authenticated requests
class Api::ArticlesController < ApplicationController
before_action :authenticate_request
def index
articles = Article.accessible_by(current_user)
render json: articles
end
def create
article = current_user.articles.build(article_params)
if article.save
render json: article, status: :created
else
render json: { errors: article.errors }, status: :unprocessable_entity
end
end
private
def article_params
params.require(:article).permit(:title, :body, :published)
end
end
Role-based authorization extends basic authentication by examining role claims in the token payload. Define authorization policies that check user roles and permissions.
class ApplicationController < ActionController::API
def authenticate_request
token = extract_token
decoded = JwtService.decode(token)
@current_user = User.find(decoded[:user_id])
rescue ActiveRecord::RecordNotFound, JWT::DecodeError
render json: { error: 'Unauthorized' }, status: :unauthorized
end
def require_role(*roles)
unless roles.include?(current_user&.role)
render json: { error: 'Forbidden' }, status: :forbidden
end
end
def extract_token
header = request.headers['Authorization']
header.split(' ').last if header&.start_with?('Bearer ')
end
end
class Admin::UsersController < ApplicationController
before_action :authenticate_request
before_action -> { require_role('admin', 'superadmin') }
def index
users = User.all
render json: users
end
end
Refresh token implementation maintains long-term sessions while minimizing access token exposure time. When the access token expires, clients request a new one using the refresh token.
class TokensController < ApplicationController
skip_before_action :authenticate_request, only: [:refresh]
def refresh
refresh_token = params[:refresh_token]
decoded = JwtService.decode(refresh_token)
# Verify token type
unless decoded[:type] == 'refresh'
return render json: { error: 'Invalid token type' }, status: :unauthorized
end
# Check if token is revoked
if RefreshToken.revoked?(decoded[:jti])
return render json: { error: 'Token revoked' }, status: :unauthorized
end
user = User.find(decoded[:user_id])
tokens = TokenService.generate_tokens(user)
render json: tokens
rescue JWT::ExpiredSignature
render json: { error: 'Refresh token expired' }, status: :unauthorized
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :unauthorized
end
def revoke
authenticate_request
RefreshToken.revoke_all_for_user(current_user.id)
render json: { message: 'All tokens revoked' }
end
end
# Model for tracking refresh tokens
class RefreshToken < ApplicationRecord
belongs_to :user
def self.revoked?(jti)
exists?(jti: jti, revoked: true)
end
def self.revoke_all_for_user(user_id)
where(user_id: user_id).update_all(revoked: true)
end
end
Microservices architectures use JWTs for service-to-service authentication. A central authentication service issues tokens, and downstream services validate them using the public key.
# Authentication service
class AuthService
PRIVATE_KEY = OpenSSL::PKey::RSA.new(ENV['JWT_PRIVATE_KEY'])
def self.issue_service_token(service_name)
payload = {
sub: service_name,
iss: 'auth.example.com',
aud: '*.example.com',
iat: Time.now.to_i,
exp: 1.hour.from_now.to_i,
scope: determine_scope(service_name)
}
JWT.encode(payload, PRIVATE_KEY, 'RS256')
end
def self.determine_scope(service_name)
# Define service-specific permissions
case service_name
when 'user-service'
['users:read', 'users:write']
when 'billing-service'
['invoices:read', 'invoices:write']
else
[]
end
end
end
# Downstream service
class ServiceClient
PUBLIC_KEY = OpenSSL::PKey::RSA.new(ENV['JWT_PUBLIC_KEY'])
def self.verify_request(token)
decoded = JWT.decode(token, PUBLIC_KEY, true, {
algorithm: 'RS256',
iss: 'auth.example.com',
verify_iss: true,
aud: '*.example.com',
verify_aud: true
})[0]
decoded
rescue JWT::DecodeError => e
raise AuthorizationError, e.message
end
end
Common Pitfalls
Storing sensitive information in JWT payloads creates security vulnerabilities. Developers sometimes include password hashes, API keys, or personal identifiable information, assuming the signature provides encryption. The signature only verifies integrity, not confidentiality. Anyone with access to the token can decode and read its contents.
# Wrong - sensitive data exposed
payload = {
user_id: 123,
email: 'user@example.com',
api_key: 'sk_live_abc123', # Exposed to anyone with the token
password_hash: '$2a$12$...' # Never include password data
}
# Correct - minimal claims only
payload = {
user_id: 123,
role: 'member',
permissions: ['read']
}
Missing algorithm validation allows attackers to exploit algorithm confusion vulnerabilities. Omitting the algorithm parameter in JWT.decode or accepting any algorithm creates attack vectors.
# Vulnerable - accepts any algorithm including 'none'
decoded = JWT.decode(token, secret, true)
# Secure - explicitly specifies expected algorithm
decoded = JWT.decode(token, secret, true, { algorithm: 'HS256' })
# Even better - use constant
ALLOWED_ALGORITHMS = ['HS256'].freeze
decoded = JWT.decode(token, secret, true, { algorithm: ALLOWED_ALGORITHMS })
Disabling signature verification for debugging purposes and forgetting to re-enable it causes critical security flaws. The second parameter in JWT.decode controls signature verification.
# Dangerous - bypasses all security
decoded = JWT.decode(token, nil, false) # Never deploy this
# Correct - always verify signatures
decoded = JWT.decode(token, secret, true, { algorithm: 'HS256' })
Ignoring token expiration creates indefinite token validity. Without expiration checks, stolen tokens remain valid forever. Always set and validate expiration claims.
# Missing expiration
payload = { user_id: 123 } # No exp claim
token = JWT.encode(payload, secret, 'HS256')
# Proper expiration
payload = {
user_id: 123,
exp: 15.minutes.from_now.to_i
}
token = JWT.encode(payload, secret, 'HS256')
# Verify expiration during decode
options = {
algorithm: 'HS256',
verify_expiration: true,
exp_leeway: 30 # Allow 30 seconds clock skew
}
decoded = JWT.decode(token, secret, true, options)
Using the same secret across multiple environments or applications compromises security. A leaked secret in development environments exposes production systems when secrets aren't separated.
# Bad - same secret everywhere
SECRET = 'shared_secret_across_all_environments'
# Good - environment-specific secrets
class JwtService
SECRET = case Rails.env
when 'production'
Rails.application.credentials.jwt_secret_production
when 'staging'
Rails.application.credentials.jwt_secret_staging
else
'development_secret_not_used_in_production'
end
end
Failing to implement token revocation leaves no mechanism to invalidate compromised tokens. While stateless validation offers performance benefits, critical scenarios require revocation capability.
# Implement selective revocation with Redis
class TokenBlacklist
REDIS = Redis.new
def self.revoke(token)
decoded = JWT.decode(token, secret, false)[0] # Don't verify to get exp
ttl = decoded['exp'] - Time.now.to_i
return if ttl <= 0
jti = decoded['jti'] || Digest::SHA256.hexdigest(token)
REDIS.setex("blacklist:#{jti}", ttl, '1')
end
def self.revoked?(token)
decoded = JWT.decode(token, secret, false)[0]
jti = decoded['jti'] || Digest::SHA256.hexdigest(token)
REDIS.exists?("blacklist:#{jti}")
end
end
# Check blacklist during authentication
def authenticate_request
token = extract_token
if TokenBlacklist.revoked?(token)
return render json: { error: 'Token revoked' }, status: :unauthorized
end
decoded = JwtService.decode(token)
@current_user = User.find(decoded[:user_id])
end
Including unnecessary claims increases token size and information exposure. Large tokens impact network performance and increase the attack surface.
# Bloated token with unnecessary data
payload = {
user_id: 123,
email: 'user@example.com',
name: 'John Doe',
address: '123 Main St',
phone: '555-0100',
preferences: { theme: 'dark', language: 'en' },
metadata: { created_at: '2024-01-01', last_login: '2024-06-15' }
}
# Lean token with essential claims only
payload = {
sub: '123', # User ID
role: 'member',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i
}
Not validating issuer and audience claims allows tokens from one system to work on another. This creates security boundaries violations in multi-service architectures.
# Service A issues token
token_a = JWT.encode({ user_id: 123, aud: 'service-a.example.com' }, secret, 'HS256')
# Service B must verify audience
def verify_token(token)
options = {
algorithm: 'HS256',
aud: 'service-b.example.com', # Only accept tokens for this service
verify_aud: true
}
JWT.decode(token, secret, true, options)
rescue JWT::InvalidAudError
raise AuthorizationError, 'Token not valid for this service'
end
Forgetting clock skew tolerance causes valid tokens to fail near expiration boundaries. Network latency and minor time differences between servers require leeway in time-based validations.
# Strict validation may reject valid tokens
options = {
algorithm: 'HS256',
verify_expiration: true
}
# Better - allow reasonable clock skew
options = {
algorithm: 'HS256',
verify_expiration: true,
exp_leeway: 60, # 60 seconds leeway for expiration
nbf_leeway: 30 # 30 seconds leeway for not-before
}
Testing Approaches
Testing JWT implementations requires validating both successful authentication flows and security boundary conditions. Unit tests verify token generation, validation logic, and error handling. Integration tests confirm end-to-end authentication workflows.
require 'rails_helper'
RSpec.describe JwtService do
let(:user) { create(:user, id: 123, role: 'admin') }
let(:secret) { 'test_secret_key' }
before do
allow(JwtService).to receive(:secret).and_return(secret)
end
describe '.encode' do
it 'creates a valid token with user claims' do
token = JwtService.encode(user_id: user.id, role: user.role)
decoded = JWT.decode(token, secret, true, { algorithm: 'HS256' })[0]
expect(decoded['user_id']).to eq(123)
expect(decoded['role']).to eq('admin')
expect(decoded['exp']).to be_present
end
it 'includes custom expiration time' do
exp_time = 1.hour.from_now
token = JwtService.encode({ user_id: user.id }, exp: exp_time)
decoded = JWT.decode(token, secret, true, { algorithm: 'HS256' })[0]
expect(decoded['exp']).to eq(exp_time.to_i)
end
end
describe '.decode' do
it 'decodes valid tokens' do
token = JwtService.encode(user_id: user.id, role: user.role)
decoded = JwtService.decode(token)
expect(decoded[:user_id]).to eq(123)
expect(decoded[:role]).to eq('admin')
end
it 'raises error for invalid tokens' do
expect {
JwtService.decode('invalid.token.here')
}.to raise_error(JWT::DecodeError)
end
it 'raises error for expired tokens' do
token = JwtService.encode({ user_id: user.id }, exp: 1.hour.ago)
expect {
JwtService.decode(token)
}.to raise_error(JWT::ExpiredSignature)
end
it 'raises error for tokens with wrong algorithm' do
other_secret = 'different_secret'
token = JWT.encode({ user_id: user.id }, other_secret, 'HS512')
expect {
JwtService.decode(token)
}.to raise_error(JWT::IncorrectAlgorithm)
end
end
end
Controller tests verify authentication middleware and authorization logic across different scenarios.
require 'rails_helper'
RSpec.describe Api::ArticlesController, type: :controller do
let(:user) { create(:user, role: 'member') }
let(:admin) { create(:user, role: 'admin') }
describe 'GET #index' do
context 'with valid token' do
before do
token = JwtService.encode(user_id: user.id, role: user.role)
request.headers['Authorization'] = "Bearer #{token}"
end
it 'returns articles' do
create_list(:article, 3)
get :index
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body).length).to eq(3)
end
end
context 'without token' do
it 'returns unauthorized' do
get :index
expect(response).to have_http_status(:unauthorized)
end
end
context 'with expired token' do
before do
token = JwtService.encode({ user_id: user.id }, exp: 1.hour.ago)
request.headers['Authorization'] = "Bearer #{token}"
end
it 'returns unauthorized' do
get :index
expect(response).to have_http_status(:unauthorized)
end
end
context 'with malformed token' do
before do
request.headers['Authorization'] = "Bearer invalid.token"
end
it 'returns unauthorized' do
get :index
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST #create' do
context 'with member role' do
before do
token = JwtService.encode(user_id: user.id, role: user.role)
request.headers['Authorization'] = "Bearer #{token}"
end
it 'creates article' do
expect {
post :create, params: { article: { title: 'Test', body: 'Content' } }
}.to change(Article, :count).by(1)
end
end
context 'with admin role' do
before do
token = JwtService.encode(user_id: admin.id, role: admin.role)
request.headers['Authorization'] = "Bearer #{token}"
end
it 'creates article with admin privileges' do
post :create, params: {
article: { title: 'Test', body: 'Content', featured: true }
}
expect(Article.last.featured).to be true
end
end
end
end
Security-focused tests verify protection against common vulnerabilities like algorithm confusion and token tampering.
RSpec.describe 'JWT Security' do
let(:secret) { 'secure_secret_key' }
let(:public_key) { OpenSSL::PKey::RSA.generate(2048).public_key }
it 'rejects tokens with algorithm none' do
payload = { user_id: 123 }
header = { alg: 'none' }
# Manually construct token with 'none' algorithm
segments = [
Base64.urlsafe_encode64(header.to_json, padding: false),
Base64.urlsafe_encode64(payload.to_json, padding: false),
''
]
malicious_token = segments.join('.')
expect {
JWT.decode(malicious_token, secret, true, { algorithm: 'HS256' })
}.to raise_error(JWT::IncorrectAlgorithm)
end
it 'rejects tokens signed with different secret' do
token = JWT.encode({ user_id: 123 }, 'wrong_secret', 'HS256')
expect {
JWT.decode(token, secret, true, { algorithm: 'HS256' })
}.to raise_error(JWT::VerificationError)
end
it 'rejects tampered payloads' do
token = JWT.encode({ user_id: 123, role: 'member' }, secret, 'HS256')
# Tamper with payload
parts = token.split('.')
tampered_payload = Base64.urlsafe_encode64(
{ user_id: 123, role: 'admin' }.to_json,
padding: false
)
tampered_token = [parts[0], tampered_payload, parts[2]].join('.')
expect {
JWT.decode(tampered_token, secret, true, { algorithm: 'HS256' })
}.to raise_error(JWT::VerificationError)
end
it 'validates issuer claim' do
token = JWT.encode(
{ user_id: 123, iss: 'evil.example.com' },
secret,
'HS256'
)
expect {
JWT.decode(token, secret, true, {
algorithm: 'HS256',
iss: 'auth.example.com',
verify_iss: true
})
}.to raise_error(JWT::InvalidIssuerError)
end
it 'validates audience claim' do
token = JWT.encode(
{ user_id: 123, aud: 'other-service.example.com' },
secret,
'HS256'
)
expect {
JWT.decode(token, secret, true, {
algorithm: 'HS256',
aud: 'api.example.com',
verify_aud: true
})
}.to raise_error(JWT::InvalidAudError)
end
end
Integration tests confirm complete authentication workflows including login, token usage, and refresh token operations.
RSpec.describe 'Authentication flow', type: :request do
let(:user) { create(:user, email: 'test@example.com', password: 'password123') }
describe 'POST /auth/login' do
it 'returns tokens for valid credentials' do
post '/auth/login', params: {
email: user.email,
password: 'password123'
}
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['access_token']).to be_present
expect(json['refresh_token']).to be_present
end
it 'rejects invalid credentials' do
post '/auth/login', params: {
email: user.email,
password: 'wrong_password'
}
expect(response).to have_http_status(:unauthorized)
end
end
describe 'Token refresh flow' do
let(:tokens) { TokenService.generate_tokens(user) }
it 'issues new access token with valid refresh token' do
post '/auth/refresh', params: {
refresh_token: tokens[:refresh_token]
}
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['access_token']).to be_present
expect(json['access_token']).not_to eq(tokens[:access_token])
end
it 'rejects expired refresh tokens' do
expired_token = JWT.encode(
{ user_id: user.id, type: 'refresh', exp: 1.day.ago.to_i },
TokenService::SECRET,
'HS256'
)
post '/auth/refresh', params: { refresh_token: expired_token }
expect(response).to have_http_status(:unauthorized)
end
end
end
Reference
Standard Claims
| Claim | Name | Type | Description |
|---|---|---|---|
| iss | Issuer | String | Identifies principal that issued the token |
| sub | Subject | String | Identifies principal that is the subject of the token |
| aud | Audience | String or Array | Identifies recipients that the token is intended for |
| exp | Expiration Time | NumericDate | Time after which the token must not be accepted |
| nbf | Not Before | NumericDate | Time before which the token must not be accepted |
| iat | Issued At | NumericDate | Time at which the token was issued |
| jti | JWT ID | String | Unique identifier for the token |
Signing Algorithms
| Algorithm | Type | Key Type | Description |
|---|---|---|---|
| HS256 | HMAC | Symmetric | HMAC using SHA-256 hash algorithm |
| HS384 | HMAC | Symmetric | HMAC using SHA-384 hash algorithm |
| HS512 | HMAC | Symmetric | HMAC using SHA-512 hash algorithm |
| RS256 | RSA | Asymmetric | RSASSA-PKCS1-v1_5 using SHA-256 |
| RS384 | RSA | Asymmetric | RSASSA-PKCS1-v1_5 using SHA-384 |
| RS512 | RSA | Asymmetric | RSASSA-PKCS1-v1_5 using SHA-512 |
| ES256 | ECDSA | Asymmetric | ECDSA using P-256 curve and SHA-256 |
| ES384 | ECDSA | Asymmetric | ECDSA using P-384 curve and SHA-384 |
| ES512 | ECDSA | Asymmetric | ECDSA using P-521 curve and SHA-512 |
JWT.encode Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| payload | Hash | Yes | Claims and data to encode in token |
| key | String or Key Object | Yes | Secret key or private key for signing |
| algorithm | String | Yes | Signing algorithm (HS256, RS256, etc) |
| header_fields | Hash | No | Additional header fields to include |
JWT.decode Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | String | Yes | JWT token to decode and verify |
| key | String or Key Object | Yes | Secret key or public key for verification |
| verify | Boolean | Yes | Whether to verify signature (always use true) |
| options | Hash | No | Verification options and claim validators |
Verification Options
| Option | Type | Default | Description |
|---|---|---|---|
| algorithm | String or Array | none | Required algorithm(s) for verification |
| verify_expiration | Boolean | true | Verify expiration claim |
| verify_not_before | Boolean | true | Verify not-before claim |
| verify_iss | Boolean | false | Verify issuer claim |
| verify_aud | Boolean | false | Verify audience claim |
| verify_iat | Boolean | false | Verify issued-at claim |
| verify_jti | Proc | nil | Custom JWT ID validator |
| iss | String | nil | Expected issuer value |
| aud | String or Array | nil | Expected audience value(s) |
| exp_leeway | Integer | 0 | Seconds of leeway for expiration check |
| nbf_leeway | Integer | 0 | Seconds of leeway for not-before check |
Common Error Classes
| Error Class | Raised When |
|---|---|
| JWT::DecodeError | Base class for all JWT decode errors |
| JWT::VerificationError | Signature verification fails |
| JWT::ExpiredSignature | Token expiration time has passed |
| JWT::IncorrectAlgorithm | Algorithm doesn't match expected algorithm |
| JWT::InvalidIssuerError | Issuer claim doesn't match expected value |
| JWT::InvalidAudError | Audience claim doesn't match expected value |
| JWT::InvalidSubError | Subject claim doesn't match expected value |
| JWT::InvalidJtiError | JWT ID claim validation fails |
| JWT::InvalidIatError | Issued-at claim validation fails |
| JWT::ImmatureSignature | Current time is before not-before time |
Token Lifecycle Best Practices
| Stage | Recommendation |
|---|---|
| Generation | Use strong secrets (32+ bytes), set expiration, include minimal claims |
| Storage | Use httpOnly cookies or localStorage with appropriate security model |
| Transmission | Always use HTTPS, include in Authorization header |
| Validation | Verify signature, check expiration, validate issuer and audience |
| Refresh | Implement refresh tokens with rotation, store in database with revocation |
| Revocation | Maintain blacklist or whitelist for critical security events |
Security Checklist
| Check | Status |
|---|---|
| Algorithm explicitly specified in decode | |
| Signature verification enabled | |
| Expiration claim present and validated | |
| Strong secret with sufficient entropy | |
| No sensitive data in payload | |
| HTTPS enforced for token transmission | |
| Issuer and audience validated | |
| Token revocation mechanism implemented | |
| Refresh tokens stored securely | |
| Clock skew tolerance configured |