In this article
April 27, 2026
April 27, 2026

How to handle JWT in Ruby

Everything you need to know to implement and validate JWTs securely in Ruby: from creating JWTs, to signing and verifying them with JWKS, handling custom claims, and best practices you should be following.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Ruby developers have a clear advantage when it comes to JWTs: the jwt gem is the single dominant library for the language, it maps closely to Ruby idioms, and it builds on OpenSSL, which ships with every Ruby installation. There is no ecosystem fragmentation, no competing libraries to evaluate, and no framework-specific abstraction hiding what is happening.

This guide walks through everything you need to know to safely consume, validate, and work with JWTs in Ruby, including HS256 and RS256 verification, JWKS, the new v3 object-oriented API, Rails integration patterns, key rotation strategies, and common pitfalls. Let's dive right in.

!!Need to inspect a token? Use the WorkOS JWT Debugger to decode and inspect your JWTs directly in the browser. It's a quick way to verify your token's iss, aud, sub, and other claims while debugging.!!

JWT 101

A JSON Web Token is a compact, URL-safe token format used to securely transmit information between systems. At a high level, a JWT lets one system make a signed statement about a user or service, and lets another system verify that statement without needing to look anything up in a database.

They are typically used to indicate a user's identity and/or assert permissions and roles.

A JWT is composed of three parts, each Base64URL-encoded and separated by dots:

  
header.payload.signature
  

Header

The header contains metadata about the token, most importantly the signing algorithm used to create the signature (e.g., HMAC, RSA, or ECDSA). This tells the verifier how the token was signed and how it should be validated.

A typical header before encoding:

  
{
  "alg": "RS256",
  "typ": "JWT"
}
  

In this example, alg is set to RS256, representing RSA with SHA-256, and typ identifies this as a JWT.

Payload

The payload contains the actual data the token encodes. These data points are called claims.

Claims are pieces of information about the subject of the token and additional context about how it should be used. Some claims are registered and standardized, like iss, sub, aud, and exp (for the full list check the JWT claims registry). Others are custom and application-specific.

Example payload:

  
{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "email": "hgranger@hogwarts.example",
  "roles": ["admin", "editor"],
  "iat": 1716239022,
  "iss": "your-saas-app",
  "aud": "your-api",
  "exp": 1716242622
}
  

It is important to note that the payload is not encrypted. Anyone who has the token can decode it and read the claims. Do not put passwords, secrets, or high-risk PII in JWT payloads.

Signature

The signature ensures the token's integrity and confirms that it was issued by a trusted source. It is created by hashing the Base64URL-encoded header and payload with a secret key (for symmetric algorithms like HS256) or a private key (for asymmetric algorithms like RS256). The resulting hash is then Base64URL-encoded and appended to the token.

When a JWT is received, the verifier recomputes the signature using the appropriate key and compares it to the signature included in the token. If they do not match, the token has been tampered with and must be rejected.

JWTs are protected via JSON Web Signature (JWS). JWS is used to share data between parties when confidentiality is not required, because the claims within a JWS can be read by anyone (they are simply Base64URL-encoded). The signature provides authentication, not encryption. Some of the cryptographic algorithms JWS uses are HMAC, RSA, and ECDSA.

Algorithm Description Use case
HS256 HMAC with shared secret Simple systems, internal services
RS256 RSA with public/private keys Authorization servers, external IdPs
ES256 ECDSA with elliptic-curve keys Modern IdPs, compact signatures

JWT library for Ruby

The jwt gem (commonly referred to as ruby-jwt) is the standard library for handling JWTs in Ruby. It is actively maintained, has over 3,700 GitHub stars, and provides support for all major signing algorithms: HMAC, RSA, ECDSA, and RSASSA-PSS natively via OpenSSL, plus EdDSA through the separate jwt-eddsa gem.

The gem also has built-in JWK and JWKS support, including key generation, import, export, and a KeyFinder that resolves keys by kid from a JWKS set.

Version 3.0 (released June 2025) introduced a new object-oriented API with JWT::Token and JWT::EncodedToken classes. This guide covers both the classic JWT.encode/JWT.decode API and the newer v3 API so you can choose the style that fits your application.

Install it with Bundler by adding the following to your Gemfile:

  
gem 'jwt', '~> 3.1'
  

Then run bundle install. Or install it directly:

  
gem install jwt
  

Generating your keys

First, you need a set of cryptographic keys to sign your tokens.

In this tutorial, we will be using RS256. This asymmetric algorithm requires two keys: a private key to sign the token and a public key to verify it. If you already have them, move along to the next section.

!!Asymmetric algorithms use a pair of public and private keys to sign and verify the tokens. They are more secure, scalable, and better for distributed systems but also more resource-intensive and complex. For more on the various algorithms see Which algorithm should you use to sign JWTs?!!

There are many ways to generate your keys. You could generate them using OpenSSL and save them as raw PEM files that your code would read:

  
# Generate private key
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

# Extract the public key
openssl rsa -pubout -in private_key.pem -out public_key.pem
  

However, this is not a best practice. Instead, you should use JSON Web Key Sets (JWKS), especially in distributed or cloud environments.

!!JWKS vs PEM: JWKS simplifies key rotation by allowing services to fetch the latest keys from a central endpoint, making updates easier and reducing the risk of errors. PEM files require manual distribution and updates, which can be cumbersome in large systems. JWKS centralizes key distribution, ensuring that all services or clients always have the correct keys without constant manual updates.!!

If you are using a third-party identity provider (like WorkOS), they automatically generate and expose a JWKS endpoint for you. This allows clients to dynamically fetch the public keys needed for JWT verification without you having to manage the keys manually. WorkOS offers a public JWKS endpoint:

  
https://api.workos.com/sso/jwks/your-client_id
  

The response looks like this:

  
{
  "keys": [
    {
      "alg": "RS256",
      "kty": "RSA",
      "use": "sig",
      "x5c": [
        "MIIDQj3DQEBCwUA..."
      ],
      "n": "0vx7agoebGc...eKnNs",
      "e": "AQAB",
      "kid": "key_013456789",
      "x5t#S256": "ZjQzYjI0OT...NmNjU0"
    }
  ]
}

  

Clients and APIs can use this endpoint to retrieve the public keys needed to validate JWTs signed by WorkOS. Key rotation, expiration, and distribution are handled automatically by the provider.

If you are not using a third-party identity provider and want to create and manage your own JWKS in Java, you will need to:

  1. Generate a key pair (public and private keys). Java's java.security.KeyPairGenerator provides this natively through the JCA.
  2. Create a JWKS endpoint. Expose the public keys at a well-known URL (/.well-known/jwks.json) that clients and services can use to validate JWTs.
  3. Handle key rotation and management. Periodically generate new key pairs and update the JWKS. Use a key identifier (kid) to distinguish between active and retired keys.
  4. Secure your private keys. Never expose private keys through your API or any public endpoint. Store them in a secure EKM like WorkOS Vault, an HSM, or at minimum an encrypted file with restricted access.

!!If you need something fast for a proof-of-concept, you can use a tool like mkjwk.org to generate a JWK.!!

Generating an RSA key pair and JWK in Ruby

The jwt gem wraps OpenSSL keys in JWK objects and can export them as a JWKS hash ready to serve from an endpoint:

  
require 'jwt'

# Generate an RSA key pair
rsa_private = OpenSSL::PKey::RSA.generate(2048)

# Wrap it in a JWK with a kid and usage metadata
jwk = JWT::JWK.new(rsa_private, { kid: SecureRandom.uuid, use: 'sig', alg: 'RS256' })

# Export the public key as a JWKS hash (safe to expose)
jwks_hash = JWT::JWK::Set.new(jwk).export
puts JSON.pretty_generate(jwks_hash)

# The signing key (private) stays on your server
signing_key = jwk.signing_key

# The verify key (public) is what clients use
verify_key = jwk.verify_key
  

This produces a JWKS JSON object you can serve at /.well-known/jwks.json.

You can also use the RFC 7638 thumbprint as the kid instead of a random UUID, which gives you a deterministic key identifier derived from the key material itself:

  
JWT.configuration.jwk.kid_generator_type = :rfc7638_thumbprint

jwk = JWT::JWK.new(rsa_private, { use: 'sig', alg: 'RS256' })
puts jwk[:kid] # => deterministic thumbprint based on the key
  

Serving a JWKS endpoint in Rails

If you are managing your own keys, expose them at the standard well-known path:

  
# config/routes.rb
get '/.well-known/jwks.json', to: 'jwks#index'

# app/controllers/jwks_controller.rb
class JwksController < ApplicationController
  def index
    render json: Rails.application.config.jwks_public
  end
end

# config/initializers/jwt.rb
rsa_private = OpenSSL::PKey::RSA.new(Rails.application.credentials.jwt_private_key!)
jwk = JWT::JWK.new(rsa_private, { kid: 'primary', use: 'sig', alg: 'RS256' })
Rails.application.config.jwk = jwk
Rails.application.config.jwks_public = JWT::JWK::Set.new(jwk).export
  

This stores the JWK in an application-level config on boot and serves only the public portion. The private key lives in Rails encrypted credentials, which is the idiomatic way to manage secrets in Rails 7 and 8.

Creating a JWT in Ruby

Once you have your RSA keys, you can create and sign a token using the private key.

Classic API

  
require 'jwt'

rsa_private = OpenSSL::PKey::RSA.new(File.read('private_key.pem'))

payload = {
  sub: 'user_123',
  email: 'hgranger@hogwarts.example',
  roles: ['admin', 'editor'],
  department: 'engineering',
  iss: 'https://your-app.example.com',
  aud: 'your-api',
  iat: Time.now.to_i,
  exp: Time.now.to_i + (15 * 60) # 15 minutes
}

token = JWT.encode(payload, rsa_private, 'RS256', { kid: 'primary' })
puts token
  

This produces a compact JWT string. Notice that the kid is passed as part of the header fields hash (the fourth argument). Including the kid is important for key rotation, which we will cover later.

v3 object-oriented API

The v3 API introduced in ruby-jwt 3.0 gives you an explicit token object:

  
require 'jwt'

jwk = JWT::JWK.new(rsa_private, { kid: 'primary', use: 'sig', alg: 'RS256' })

token = JWT::Token.new(
  payload: {
    sub: 'user_123',
    email: 'hgranger@hogwarts.example',
    roles: ['admin', 'editor'],
    iss: 'https://your-app.example.com',
    aud: 'your-api',
    iat: Time.now.to_i,
    exp: Time.now.to_i + (15 * 60)
  },
  header: { kid: jwk.kid }
)

token.sign!(algorithm: 'RS256', key: jwk.signing_key)
puts token.jwt # => the compact JWT string
  

The v3 API is more explicit about the signing step. You build the token, then sign it as a separate operation. This makes it clearer when the token transitions from an unsigned data structure to a signed credential.

Sending the token as a Bearer token

Once the client has the JWT, it sends it in the Authorization header as a Bearer token. The Bearer prefix tells the API that whoever bears this token can use it:

  
require 'net/http'

uri = URI('https://api.example.com/protected')
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{token}"

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end
  

If you are using a popular HTTP client like Faraday or HTTParty, setting the header works the same way. On the server side, you will extract the token from this header before validating it.

Adding standard and custom claims

JWT claims fall into two categories.

Standard claims

Common registered claims include:

  • sub (subject): what the token is about, typically the user's unique identifier.
  • iss (issuer): who issued the token.
  • aud (audience): who the token is intended for.
  • exp (expiration time): when the token expires, in seconds since the Unix epoch.
  • iat (issued at): when the token was issued.
  • nbf (not before): when the token becomes valid.
  • jti (JWT ID): a unique identifier for the token, useful for revocation.

In Ruby, these are just keys in the payload hash:

  
payload = {
  sub: 'user_123',
  iss: 'https://your-app.example.com',
  aud: 'your-api',
  exp: Time.now.to_i + 900,
  iat: Time.now.to_i,
  nbf: Time.now.to_i,
  jti: SecureRandom.uuid
}
  

Custom claims

Custom claims are application-specific data added alongside the standard claims:

  
payload = {
  sub: 'user_123',
  email: 'hgranger@hogwarts.example',
  roles: ['admin', 'editor'],
  department: 'engineering',
  email_verified: true,
  feature_flags: { beta_access: true, dark_mode: false }
}
  

Because Ruby hashes are flexible, you can nest arrays, hashes, booleans, and numbers directly in the payload. The jwt gem handles serialization to JSON automatically. Just be careful not to include sensitive information, since JWT payloads are encoded, not encrypted.

Decoding a JWT

Decoding a JWT without verifying it can be useful for debugging and logging, but it should never be used for authorization decisions. Pass false as the third argument to skip verification:

  
# Decode without verification (for debugging only!)
decoded = JWT.decode(token, nil, false)
payload = decoded[0] # => {"sub"=>"user_123", "email"=>"hgranger@hogwarts.example", ...}
header  = decoded[1] # => {"alg"=>"RS256", "kid"=>"primary"}

puts header['alg']
puts header['kid']
puts payload['sub']
puts payload['email']
  

JWT.decode always returns a two-element array: the payload hash and the header hash. The signature has not been checked at this point. Do not trust any of these values until you verify the token.

About the kid claim

The kid (key ID) appears in the JWT header, not the payload:

  
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "primary"
}
  

It tells your application which public key (from a set of keys) should be used to verify the signature. This is essential when your authentication provider uses key rotation, publishing multiple public keys at a JWKS endpoint and including kid in the JWT header to indicate which key was used to sign it.

When your app receives a JWT, it extracts the kid from the header, looks up the matching public key in the JWKS, and uses that key to verify the signature. The jwt gem handles this through its jwks option and JWT::JWK::KeyFinder class, which we will use in the next section.

Verifying a JWT

Verification ensures three things: the signature is valid, the token has not expired, and the claims match your expectations.

Verifying with a local public key

If you have the public key available locally:

  
rsa_public = OpenSSL::PKey::RSA.new(File.read('public_key.pem'))

begin
  decoded = JWT.decode(
    token,
    rsa_public,
    true,              # verification enabled
    { algorithm: 'RS256' }
  )
  payload = decoded[0]
  puts "Subject: #{payload['sub']}"
  puts "Email: #{payload['email']}"

rescue JWT::ExpiredSignature
  puts 'Token has expired'
rescue JWT::IncorrectAlgorithm
  puts 'Unexpected algorithm'
rescue JWT::DecodeError => e
  puts "Token verification failed: #{e.message}"
end
  

The algorithm: 'RS256' option is critical. It tells the gem to only accept RS256-signed tokens. If you omit this, or if you accept whatever algorithm the header claims, an attacker could send a token signed with a different algorithm (like HS256 using the public key as the shared secret) and your verifier might accept it. This is the algorithm confusion attack, one of the most well-known JWT vulnerabilities.

Verifying with a JWKS

This is the recommended approach for production. Your identity provider publishes its public keys at a JWKS URL, and you fetch them to verify tokens:

  
require 'jwt'
require 'net/http'
require 'json'

JWKS_URL = 'https://api.workos.com/sso/jwks/your-client_id'

# A lambda-based JWKS loader with caching and refresh on unknown kid
jwk_loader = ->(options) do
  @cached_jwks = nil if options[:invalidate] # re-fetch if kid not found
  @cached_jwks ||= begin
    response = Net::HTTP.get(URI(JWKS_URL))
    JSON.parse(response, symbolize_names: true)
  end
end

begin
  decoded = JWT.decode(
    token,
    nil,         # key is resolved from JWKS
    true,
    {
      algorithm: 'RS256',
      jwks: jwk_loader,
      iss: 'https://your-app.example.com',
      verify_iss: true,
      aud: 'your-api',
      verify_aud: true
    }
  )
  payload = decoded[0]
  puts "Authenticated: #{payload['sub']}"

rescue JWT::ExpiredSignature
  puts 'Token has expired'
rescue JWT::InvalidIssuerError
  puts 'Invalid issuer'
rescue JWT::InvalidAudError
  puts 'Invalid audience'
rescue JWT::DecodeError => e
  puts "Verification failed: #{e.message}"
end
  

The jwks option accepts either a hash (a static JWKS) or a lambda. Using a lambda is the recommended pattern because it allows two important behaviors:

  • Caching. The JWKS is fetched once and stored in @cached_jwks. Subsequent verifications use the cached version without making network calls.
  • Automatic refresh on unknown kid. When the gem encounters a kid that is not in the cached JWKS, it calls the lambda with options[:invalidate] set to true. This clears the cache and triggers a fresh fetch from the endpoint, handling key rotation transparently.

Verifying with the v3 API

The v3 JWT::EncodedToken class separates parsing, signature verification, and claims validation into explicit steps:

  
require 'jwt'

# Parse the token
encoded_token = JWT::EncodedToken.new(token)

# Build a keyfinder from your JWKS
jwks = JWT::JWK::Set.new(jwks_hash)
jwks.filter! { |key| key[:use] == 'sig' }
key_finder = JWT::JWK::KeyFinder.new(jwks: jwks)

# Verify the signature
encoded_token.verify!(signature: {
  algorithm: 'RS256',
  key_finder: key_finder
})

# Verify claims
encoded_token.verify!(claims: {
  exp: { leeway: 30 },
  iss: { value: 'https://your-app.example.com' },
  aud: { value: 'your-api' }
})

# Now you can access the payload
payload = encoded_token.payload
puts payload['sub']
  

A notable change in v3: you must call verify! before accessing the payload. If you try to read encoded_token.payload without verifying first, the gem raises an error. This is a deliberate safety feature that prevents accidental use of unverified claims.

Adding claims validation

The classic JWT.decode API validates standard claims when you pass the appropriate options:

  
JWT.decode(token, public_key, true, {
  algorithm: 'RS256',
  iss: 'https://your-app.example.com',
  verify_iss: true,
  aud: 'your-api',
  verify_aud: true,
  verify_expiration: true,
  verify_not_before: true,
  verify_iat: true,
  leeway: 30 # seconds of clock drift tolerance
})
  

Each verify_* flag must be set to true explicitly. The leeway option adds tolerance for clock drift between systems. Thirty seconds is a reasonable default.

Handling custom claims

Once verified, custom claims are available in the payload hash:

  
payload = decoded[0]

email      = payload['email']
roles      = payload['roles']     # => ["admin", "editor"]
department = payload['department'] # => "engineering"
verified   = payload['email_verified'] # => true

# Use roles for authorization
unless roles&.include?('admin')
  raise 'Insufficient permissions'
end
  

Note that JWT.decode returns string keys in the payload hash, not symbols, regardless of how the payload was originally constructed. If you prefer symbol keys, call payload.symbolize_keys (available in Rails) or payload.transform_keys(&:to_sym).

For nested claims:

  
feature_flags = payload['feature_flags']
if feature_flags && feature_flags['beta_access']
  # Enable beta features
end
  

Integrating with Rails

Most Ruby web applications run on Rails, and there are several idiomatic patterns for JWT authentication in a Rails API.

Rack middleware approach

For Rails API-only applications, a Rack middleware that runs before your controllers is a clean way to handle JWT verification:

  
# app/middleware/jwt_authentication.rb
class JwtAuthentication
  def initialize(app)
    @app = app
  end

  def call(env)
    request = Rack::Request.new(env)

    # Skip authentication for public paths
    return @app.call(env) if public_path?(request.path)

    auth_header = env['HTTP_AUTHORIZATION']
    unless auth_header&.start_with?('Bearer ')
      return [401, { 'Content-Type' => 'application/json' }, ['{"error": "Missing token"}']]
    end

    token = auth_header.sub('Bearer ', '')

    begin
      decoded = JWT.decode(token, nil, true, {
        algorithm: 'RS256',
        jwks: jwk_loader,
        iss: ENV['JWT_ISSUER'],
        verify_iss: true,
        aud: ENV['JWT_AUDIENCE'],
        verify_aud: true
      })
      env['jwt.payload'] = decoded[0]
      @app.call(env)
    rescue JWT::DecodeError => e
      [401, { 'Content-Type' => 'application/json' }, [{ error: e.message }.to_json]]
    end
  end

  private

  def public_path?(path)
    path.start_with?('/.well-known') || path == '/health'
  end

  def jwk_loader
    ->(options) do
      @cached_jwks = nil if options[:invalidate]
      @cached_jwks ||= fetch_jwks
    end
  end

  def fetch_jwks
    response = Net::HTTP.get(URI(ENV['JWKS_URL']))
    JSON.parse(response, symbolize_names: true)
  end
end

# config/application.rb
config.middleware.use JwtAuthentication
  

Controller concern approach

If you prefer keeping authentication in the controller layer (closer to the Rails convention of before_action filters), a concern works well:

  
# app/controllers/concerns/jwt_authenticatable.rb
module JwtAuthenticatable
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_jwt!
    attr_reader :current_user_claims
  end

  private

  def authenticate_jwt!
    auth_header = request.headers['Authorization']
    unless auth_header&.start_with?('Bearer ')
      render json: { error: 'Missing token' }, status: :unauthorized and return
    end

    token = auth_header.sub('Bearer ', '')

    begin
      decoded = JWT.decode(token, nil, true, {
        algorithm: 'RS256',
        jwks: self.class.jwk_loader,
        iss: ENV['JWT_ISSUER'],
        verify_iss: true,
        aud: ENV['JWT_AUDIENCE'],
        verify_aud: true
      })
      @current_user_claims = decoded[0]
    rescue JWT::ExpiredSignature
      render json: { error: 'Token expired' }, status: :unauthorized
    rescue JWT::DecodeError => e
      render json: { error: 'Invalid token' }, status: :unauthorized
    end
  end

  class_methods do
    def jwk_loader
      @jwk_loader ||= ->(options) do
        @cached_jwks = nil if options[:invalidate]
        @cached_jwks ||= begin
          response = Net::HTTP.get(URI(ENV['JWKS_URL']))
          JSON.parse(response, symbolize_names: true)
        end
      end
    end
  end
end

# app/controllers/api/base_controller.rb
module Api
  class BaseController < ApplicationController
    include JwtAuthenticatable
  end
end

# app/controllers/api/dashboard_controller.rb
module Api
  class DashboardController < BaseController
    def show
      render json: {
        user_id: current_user_claims['sub'],
        email: current_user_claims['email'],
        roles: current_user_claims['roles']
      }
    end
  end
end
  

Caching JWKS with Rails.cache

The lambda-based JWKS loader shown earlier uses a simple instance variable cache. In a Rails application running multiple Puma workers, each worker process has its own instance variable, so the JWKS gets fetched once per worker. For most applications this is fine.

If you want to share the cache across workers (or set an explicit TTL), use Rails.cache:

  
def jwk_loader
  ->(options) do
    force_refresh = options[:invalidate] || false
    Rails.cache.fetch('jwks', force: force_refresh, expires_in: 12.hours) do
      response = Net::HTTP.get(URI(ENV['JWKS_URL']))
      JSON.parse(response, symbolize_names: true)
    end
  end
end
  

This caches the JWKS for 12 hours by default and force-refreshes when an unknown kid is encountered. If you use Memcached or Redis as your cache store, the JWKS is shared across all workers.

One important caveat: if an attacker sends many requests with random invalid kid values, each one triggers a cache invalidation and a fresh HTTP request to the JWKS endpoint. To protect against this, add rate limiting to the JWKS refresh:

  
def jwk_loader
  ->(options) do
    if options[:invalidate]
      last_refresh = Rails.cache.read('jwks_last_refresh')
      if last_refresh && Time.now - last_refresh < 5.minutes
        # Too soon to refresh, use stale cache
        return Rails.cache.read('jwks') || {}
      end
      Rails.cache.write('jwks_last_refresh', Time.now)
    end

    Rails.cache.fetch('jwks', force: options[:invalidate], expires_in: 12.hours) do
      response = Net::HTTP.get(URI(ENV['JWKS_URL']))
      JSON.parse(response, symbolize_names: true)
    end
  end
end
  

This limits JWKS re-fetching to once every five minutes, regardless of how many unknown kid values are seen.

JWT best practices (Ruby edition)

JWTs are simple in structure, but security lives in the details you enforce. Here are the practices that matter most in production Ruby applications.

  • Always verify the signature. Do not trust a token just because it decodes cleanly. Only use claims for authorization decisions after verification succeeds. With the jwt gem, always pass true as the third argument to JWT.decode, or call verify! on JWT::EncodedToken.
  • Enforce the expected algorithm. Always pass algorithm: 'RS256' (or whichever algorithm you expect) in the decode options. "Accept whatever the header says" is how algorithm confusion attacks happen. The jwt gem's README explicitly warns about this.
  • Validate critical standard claims. At minimum, validate exp (expiration), iss (issuer), and aud (audience). Set verify_iss: true, verify_aud: true, and verify_expiration: true in your decode options. If you deal with clock drift between systems, set a small leeway (30 to 60 seconds) rather than loosening validation.
  • Use a JWKS endpoint when possible. If your tokens are issued by an identity provider, verify against their JWKS so you can automatically select the right public key by kid. Use the lambda-based jwks loader for caching and automatic refresh.
  • Plan for key rotation. If you manage your own keys, publish new keys at your JWKS endpoint before you start signing with them, keep old keys available until tokens signed with them expire, and use kid to distinguish active from retired keys. The jwt gem's jwks lambda handles the verifier side of rotation automatically by re-fetching on unknown kid.
  • Enforce Bearer token format. Require tokens in the Authorization header in this exact format: Authorization: Bearer <jwt>. Treat tokens in query parameters as a problem, because they leak into logs, browser history, and referrer headers.
  • Keep access tokens short-lived. Short exp values (5 to 15 minutes) reduce the blast radius of a leaked token. If you need long sessions, use refresh tokens and rotate them.
  • Handle verification errors explicitly. The jwt gem raises specific exception classes: JWT::ExpiredSignature, JWT::InvalidIssuerError, JWT::InvalidAudError, JWT::IncorrectAlgorithm, JWT::VerificationError, and JWT::DecodeError as a catch-all. Map these to clean HTTP responses: 401 for missing, invalid, or expired tokens; 403 for valid tokens that lack required permissions.
  • Use HTTPS everywhere. JWTs are bearer credentials. If someone can intercept the request, they can replay the token.
  • Centralize JWT logic. Put verification in a Rack middleware or a Rails controller concern so every protected endpoint enforces the same checks. Do not scatter partial verification logic across individual actions.
  • Log failures carefully. Log high-level context (like kid, iss, and the reason verification failed) and never log full tokens or entire payloads. In Rails, use Rails.logger with structured tags.
  • Store secrets in Rails credentials. Do not put private keys or JWT secrets in environment variables if you can avoid it. Rails encrypted credentials (rails credentials:edit) are the idiomatic approach for managing secrets in Rails 7 and 8.
  • Test with bad tokens. Make sure your test suite covers expired tokens, tokens with wrong iss or aud, tokens signed with the wrong key, tokens with tampered payloads, missing required claims, and wrong algorithm or none edge cases. The jwt gem makes it easy to generate test tokens with specific properties.

Let WorkOS handle the heavy lifting

While handling JWTs with the jwt gem is often necessary at the API layer, it is worth stepping back and looking at the bigger picture: how those tokens are issued in the first place.

If you are building authentication flows, especially ones that involve Single Sign-On (SSO), SCIM provisioning, or multi-tenant identity, there is a lot more to solve than signing and verifying tokens. You need to support different identity providers, manage users and directories, rotate keys safely, and issue tokens that downstream services can trust.

WorkOS provides a modern API for enterprise-ready authentication features, letting you integrate SSO (SAML, OIDC, and more), manage users and directories, and issue secure tokens without building and maintaining a full auth stack from scratch. WorkOS has a Ruby SDK that handles the OAuth flow, token exchange, and user management. It is especially useful if you need to support enterprise customers or want to offer a "Login with your company" experience. And it is free for up to 1,000,000 monthly active users.

If you are already running a Rails app, you may also want to check out the existing guide on building authentication in Rails, which covers the full authentication story beyond just JWTs.

Sign up for WorkOS today.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.