BlogImplementing JWT Authentication in Ruby on Rails
·Updated November 7, 2026·6 min read·Naima Tun Nissa

Implementing JWT Authentication in Ruby on Rails

Configure JWT authentication in Ruby on Rails with the ruby-jwt gem: dedicated secrets, credentials storage, controller filters, and devise-jwt guidance.

Implementing JWT Authentication in Ruby on Rails

Rails API applications almost always reach for the ruby-jwt gem to issue and verify tokens, and JWT.encode / JWT.decode look simple enough to wire up in a few lines. Most real JWT bugs in Rails apps don't come from the gem — they come from three specific habits that are easy to fall into if you're coming from Rails' session-and-cookie world: reusing secret_key_base, skipping the explicit algorithm allowlist on decode, and treating a decoded payload as verified. This guide covers configuration end to end, with those three pitfalls called out specifically.

Install the Gem

# Gemfile
gem 'jwt'
bundle install

Generate a Dedicated JWT Secret — Don't Reuse `secret_key_base`

Rails already has a secret: Rails.application.secret_key_base, used for session cookie signing, CSRF tokens, and (depending on your Rails version) MessageEncryptor/MessageVerifier. It's tempting to pass that same value to JWT.encode because it's already there and already a secret. Don't. secret_key_base protects a different trust boundary — your session and CSRF machinery. If your JWT secret ever leaks (a log line, a misconfigured error tracker, a committed .env), you don't want that same leak to also let an attacker forge session cookies, and vice versa. Generate and store a separate, dedicated JWT secret:

require 'securerandom'
jwt_secret = SecureRandom.hex(32) # 256 bits, CSPRNG-backed

SecureRandom wraps OpenSSL's random number generator — the same entropy guarantee as crypto.randomBytes in Node or secrets.token_hex in Python. Never use rand or a typed string for this.

Storing the Secret

Rails 5.2+ ships with encrypted credentials (config/credentials.yml.enc, decrypted at runtime by config/master.key):

EDITOR="code --wait" bin/rails credentials:edit
jwt:
  secret: <your generated hex secret>
Rails.application.credentials.jwt[:secret]

config/master.key must never be committed — it's the equivalent of a .env file and should be in .gitignore by default in a fresh Rails app. For simpler deployments, an environment variable (ENV['JWT_SECRET']) works just as well; pick one approach per environment and don't mix conventions between services, same as any other HMAC key.

Issuing and Verifying Tokens

require 'jwt'

payload = { sub: user.id, exp: 1.hour.from_now.to_i }
token = JWT.encode(payload, jwt_secret, 'HS256')

begin
  decoded = JWT.decode(token, jwt_secret, true, { algorithm: 'HS256' })
rescue JWT::ExpiredSignature
  # token expired
rescue JWT::DecodeError
  # invalid signature, malformed token, or algorithm mismatch
end

The third positional argument to JWT.decode (true here) turns verification on — passing false only decodes the payload without checking the signature at all, which is occasionally useful for debugging but is never correct for an authentication path. The algorithm: option in the fourth argument is just as important: always pass an explicit algorithm allowlist. Letting the library accept whatever alg the token itself claims opens the door to algorithm-confusion attacks, the same risk covered in our HS256 vs RS256 guide — pinning it here is the Ruby-specific version of a rule that applies to every JWT library in every language.

Wiring It Into a Controller

A typical before_action filter, extracting the token from the Authorization: Bearer header:

class ApplicationController < ActionController::API
  before_action :authenticate_request

  private

  def authenticate_request
    header = request.headers['Authorization']
    token = header&.split(' ')&.last

    decoded = JWT.decode(token, jwt_secret, true, { algorithm: 'HS256' })
    @current_user_id = decoded[0]['sub']
  rescue JWT::ExpiredSignature
    render json: { error: 'Token expired' }, status: :unauthorized
  rescue JWT::DecodeError
    render json: { error: 'Invalid token' }, status: :unauthorized
  end

  def jwt_secret
    Rails.application.credentials.jwt[:secret]
  end
end

Rescuing JWT::ExpiredSignature and JWT::DecodeError separately isn't just tidier error messages — it's the difference between telling a client "your session is stale, refresh" versus "something is actually wrong with this token," which matters for how a frontend decides whether to retry a refresh flow or force a full re-login.

Refresh Tokens and Expiry

Keep access tokens short-lived and pair them with a longer-lived refresh token, the same pattern covered in JWT token expiration best practices and access token vs refresh token — nothing about the pattern is Rails-specific, only the storage layer changes (an ActiveRecord table for refresh tokens with revocation support is the usual choice, rather than keeping them stateless).

If You're Using Devise

If your app already uses Devise for authentication, don't hand-roll JWT alongside it — devise-jwt is the standard integration and handles token revocation strategies (denylist, allowlist, JTI matcher) that a from-scratch implementation easily gets wrong. This guide's approach (plain ruby-jwt with a manual controller filter) is the right fit for a lean API-only app without Devise; reach for devise-jwt instead of this pattern once Devise is already in the stack.

Rotating the Secret Without Downtime

Same kid-based overlap strategy as HS256 in any other language:

JWT_KEYS = {
  '2026-q4' => Rails.application.credentials.jwt[:secret_current],
  '2026-q3' => Rails.application.credentials.jwt[:secret_previous],
}.freeze

def decode_with_rotation(token)
  header = JWT.decode(token, nil, false)[1] # peek at header only, no verification yet
  secret = JWT_KEYS.fetch(header['kid']) { raise JWT::DecodeError, 'unknown kid' }
  JWT.decode(token, secret, true, { algorithm: 'HS256' })
end

Accept both keys during a 24–48 hour overlap window, then drop the previous secret once outstanding tokens expire.

Frequently Asked Questions

Can I just use `Rails.application.secret_key_base` as my JWT secret?

You can, but you shouldn't — it couples your JWT trust boundary to your session/CSRF trust boundary, so a leak of either compromises both. Generate a separate secret dedicated to JWT signing.

`ruby-jwt` or `devise-jwt` — which should I use?

If you're already using Devise for authentication, use devise-jwt — it handles revocation correctly out of the box. If you're building a lean API without Devise, plain ruby-jwt with a manual controller filter (as shown above) is simpler and gives you full control.

Does decoding a JWT in Rails verify it?

Only if you pass true as the third argument to JWT.decode along with an explicit algorithm: option. Calling it with false just base64-decodes the payload — anyone can do that without the secret, and it proves nothing about authenticity.

HS256 or RS256 for a Rails API?

If a single Rails service both issues and verifies its own tokens, HS256 with a dedicated secret is simpler to manage and equally secure. Move to RS256 only once something besides your own Rails app needs to verify tokens independently — see HS256 vs RS256.

How do I handle an expired token gracefully on the frontend?

Rescue JWT::ExpiredSignature distinctly from other decode errors (as shown above) so your API can return a specific "expired, please refresh" response rather than a generic 401 — that distinction is what lets a frontend attempt a silent refresh instead of forcing a full re-login.

Related Implementations

Language-specific JWT secret patterns for other stacks:

---

Generate a secure JWT secret for your Rails app now: our free JWT Secret Generator runs entirely in your browser.

*Related: HS256 vs RS256 · JWT Token Expiration Best Practices · Access Token vs Refresh Token*

Advertisement

Written by

Naima Tun Nissa

Software Engineer Lead, Arcloops

Naima Tun Nissa is Software Engineer Lead at Arcloops, where she works on production authentication systems and backend implementation. She writes the framework and language-specific JWT implementation guides on this site.