Zero-Dependency JWT Decoding in Ruby
Ruby's standard library has native URL-safe Base64 support via Base64.urlsafe_decode64, so you can decode a JWT payload with zero external gems.
require "base64"
require "json"
# Decode JWT payload without verifying signature. Inspection only.
def decode_payload(token)
parts = token.split(".")
raise "Not a JWT" unless parts.length == 3
# Add padding for urlsafe_decode64 (Ruby is strict about length % 4)
segment = parts[1]
segment += "=" * (-segment.length % 4)
payload = Base64.urlsafe_decode64(segment)
JSON.parse(payload)
end
# Usage
claims = decode_payload("eyJhbGciOi...")
puts claims["sub"]
puts claims["exp"]Use for logging, debugging, and developer tools. Never for authorization — no signature is verified.
Using the ruby-jwt Gem — The Community Standard
The jwt gem (ruby-jwt on GitHub) is the de-facto standard: 3k+ stars, used by Devise-JWT, JWT Sessions, and every Rails auth tutorial on the internet.
Installation
# Gemfile
gem "jwt", "~> 2.8"
# Terminal
bundle installVerify with HS256 Secret
require "jwt"
class JsonWebToken
ALGORITHM = "HS256"
SECRET = ENV.fetch("JWT_SECRET")
def self.encode(payload, expires_in: 24.hours)
payload[:exp] = expires_in.from_now.to_i
payload[:iat] = Time.now.to_i
payload[:iss] = "https://auth.example.com"
payload[:aud] = "https://api.example.com"
JWT.encode(payload, SECRET, ALGORITHM)
end
def self.decode(token)
decoded, _header = JWT.decode(
token,
SECRET,
true,
{
algorithm: ALGORITHM,
iss: "https://auth.example.com",
verify_iss: true,
aud: "https://api.example.com",
verify_aud: true,
verify_expiration: true,
verify_iat: true,
leeway: 30, # Seconds of clock drift
}
)
HashWithIndifferentAccess.new(decoded)
rescue JWT::ExpiredSignature
raise "Token expired"
rescue JWT::InvalidIssuerError
raise "Wrong issuer"
rescue JWT::InvalidAudError
raise "Wrong audience"
rescue JWT::DecodeError => e
raise "Invalid token: #{e.message}"
end
endVerify with RS256 (Public Key)
require "openssl"
public_key = OpenSSL::PKey::RSA.new(File.read("public.pem"))
decoded, header = JWT.decode(
token,
public_key,
true,
{
algorithms: ["RS256"], # Array — reject alg confusion
iss: "https://auth.example.com",
verify_iss: true,
aud: "https://api.example.com",
verify_aud: true,
}
)Rails 7 Controller Middleware
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authenticate_request
attr_reader :current_user
private
def authenticate_request
header = request.headers["Authorization"]
header = header.split(" ").last if header # Strip "Bearer "
unless header
render json: { error: "Missing token" }, status: :unauthorized
return
end
begin
@decoded = JsonWebToken.decode(header)
@current_user = User.find(@decoded[:sub])
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :unauthorized
rescue StandardError => e
render json: { error: e.message }, status: :unauthorized
end
end
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def me
render json: current_user
end
endVerify with JWKS (Auth0, Cognito, Azure AD)
require "net/http"
require "json"
JWKS_URL = "https://auth.example.com/.well-known/jwks.json"
jwks_loader = ->(options) {
Rails.cache.fetch("jwks", expires_in: 1.hour, force: options[:invalidate]) do
JSON.parse(Net::HTTP.get(URI(JWKS_URL)), symbolize_names: true)
end
}
decoded, header = JWT.decode(
token,
nil, # Key is looked up from JWKS
true,
{
algorithms: ["RS256"],
jwks: jwks_loader,
iss: "https://auth.example.com",
verify_iss: true,
aud: "https://api.example.com",
verify_aud: true,
}
)Devise-JWT Setup (Rails Apps with Devise)
# Gemfile
gem "devise"
gem "devise-jwt"
# config/initializers/devise.rb
Devise.setup do |config|
config.jwt do |jwt|
jwt.secret = ENV.fetch("DEVISE_JWT_SECRET_KEY")
jwt.dispatch_requests = [["POST", %r{^/login$}]]
jwt.revocation_requests = [["DELETE", %r{^/logout$}]]
jwt.expiration_time = 24.hours.to_i
end
end
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
endCommon Ruby JWT Errors
- JWT::ExpiredSignature — exp claim is in the past. Client must refresh.
- JWT::ImmatureSignature — nbf claim is in the future. Check client clock.
- JWT::VerificationError — signature does not match the secret/public key.
- JWT::DecodeError — token is malformed (not three dot-separated segments, invalid base64, invalid JSON). Catch this as a superclass.
- JWT::InvalidIssuerError / JWT::InvalidAudError — iss or aud does not match your options.
- JWT::IncorrectAlgorithm — token alg does not match the algorithms you passed. This is the alg confusion defence.
Security Best Practices for Ruby JWT
- Always pass
algorithms: ["HS256"]oralgorithms: ["RS256"]as an array. Never allow the token to pick its own algorithm — that is the alg confusion attack. - Never allow the "none" algorithm. ruby-jwt 2.x rejects this by default; keep it that way.
- HS256 secret must be at least 32 random bytes. Generate with
SecureRandom.hex(32). Store in Rails credentials or env, never in code. - Use
leeway: 30for reasonable clock drift tolerance — do not set it larger than 60 seconds. - For production JWKS, cache the response with
Rails.cache.fetch("jwks", expires_in: 1.hour). Rotate the cache whenkidmismatch is detected. - Do not log full tokens. Log only
jti(JWT ID) andsubfor audit trails.
Key Facts
- Standard gem:
- jwt (ruby-jwt on GitHub), require "jwt"
- Rails auth:
- devise-jwt gem wraps ruby-jwt into a Devise strategy
- HS256 secret:
- 32+ random bytes. SecureRandom.hex(32).
- JWKS:
- Pass jwks: loader in options — ruby-jwt has built-in support
- Ruby version:
- 2.7 or newer; tested on Ruby 3.0, 3.1, 3.2, 3.3
Related JWT Tools
- JWT Decoder Online — decode any token instantly
- JWT Decoder Python — PyJWT for Flask/Django/FastAPI
- JWT Decoder Node.js — jsonwebtoken and jose patterns
- JWT Decoder Java — jjwt and Spring Boot patterns
- JWT Decoder Go — golang-jwt/jwt v5 and Gin/Fiber