JWT tokens leak, expire, and get stolen. Proper rotation and expiry prevent all three.

We shipped our first Rails API with JWT in 2022 and thought we had done it right — short tokens, a secret key, the JWT gem wired into ApplicationController. Six months later, a contractor's laptop was stolen. We had no way to invalidate that token.

It was valid for another 29 days.

That gap between "JWT works" and "JWT is secure" is where Rails API JWT security actually lives.

What Everyone Gets Wrong on Day One

We shipped 30-day tokens in v1 because nobody wants to think about refresh logic. The jwt gem makes this easy to do wrong. You encode a payload, set exp to 30 days out, and every request validates fine.

Nothing breaks. Nothing warns you.

Production hums along until someone with an old token — or a stolen one — starts making requests you never authorized.

The Payload That Caused the First Production Incident

Our initial payload looked like this:

payload = {
  user_id: user.id,
  exp: 30.days.from_now.to_i
}
JWT.encode(payload, Rails.application.secret_key_base, 'HS256')

That exp value felt responsible.

What we missed was that 30 days is forever in API time — a compromised token from a Tuesday breach was still valid the following month, making requests we had no visibility into.

We changed it to 15 minutes and added a refresh token flow.

Fifteen minutes is completely arbitrary, for the record. Every guide says "short-lived tokens" and then refuses to define short.

Why the Blocklist Table Hit 11 Million Rows in Four Months

Stateless JWT works until you need to revoke a specific token. You cannot un-issue a signed JWT. Once it is out, it validates against your secret until it expires.

The standard workaround is a blocklist — a Redis set of revoked jti values (JWT IDs).

payload = {
  user_id: user.id,
  jti: SecureRandom.uuid,
  exp: 15.minutes.from_now.to_i
}

Then, in the middleware, the revocation check is a single lookup:

if RevokedToken.exists?(jti: decoded_payload['jti'])
  render json: { error: 'Token revoked' }, status: :unauthorized
end

What nobody warned us about was the growth rate; With 15-minute tokens and 60,000 active users, you add roughly 2.9 million rows per day if you are doing logout-on-revoke.

We hit 11 million rows before a developer noticed the table in a slow query log. We had forgotten to add a cleanup job — add the cleanup job on day one, not after the slow query alert.

Moving the Revocation Check to Redis

The database blocklist worked until p99 on authenticated endpoints hit 340ms.

Every request was doing a jti lookup against an 11-million-row table with a partial index that was not being used correctly.

# On token issue
$redis.setex("revoked:#{jti}", 15.minutes.to_i, '1')
# On token validate
if $redis.exists?("revoked:#{jti}")
  render json: { error: 'Token revoked' }, status: :unauthorized
end

p99 dropped from 340ms to 41ms.

The Redis approach has its own failure mode — if Redis goes down with no fallback, every revocation check fails open. We chose to fail closed and return 503 when Redis was unreachable. We still debate whether that was the right call.

The Middleware Nobody Writes Until They Get Burned

Most Rails JWT guides stop at decode and rescue.

def authenticate_request!
  token = request.headers['Authorization']&.split(' ')&.last
  decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256')
  @current_user = User.find(decoded[0]['user_id'])
rescue JWT::ExpiredSignature
  render json: { error: 'Token expired' }, status: :unauthorized
rescue JWT::DecodeError
  render json: { error: 'Invalid token' }, status: :unauthorized
end

That is fine for demos.

In production, that middleware is missing three things that will cost you eventually:

  • Rate limiting on authentication failures per IP (we use Rack::Attack — 5 failures per minute per IP triggers a 5-minute block)
  • Logging of jti and user_id on every authenticated request for audit trails
  • Separate rescue for JWT::InvalidIssuerError when verifying the iss claim

The iss claim is worth the two lines it takes.

When you add third-party integrations that also issue JWTs, skipping issuer verification means a token from integration A validates on integration B.

The Refresh Flow Race Condition That Paged Us at 2 AM

Refresh tokens are not complicated until they are.

We stored refresh tokens hashed in the database, one per user session.

The flow was standard — access token expires, client sends refresh token, server issues a new access token and rotates the refresh token.

def refresh
  token_hash = Digest::SHA256.hexdigest(params[:token])
  refresh_token = RefreshToken.find_by(token_hash: token_hash)
  return render json: { error: 'Invalid' }, status: :unauthorized unless refresh_token&.active?
  new_access = issue_access_token(refresh_token.user)
  refresh_token.rotate!
  render json: { access_token: new_access, refresh_token: refresh_token.new_value }
end

At 2:14 am on a Thursday, we got paged because refresh was returning 401 for 30% of requests.

The rotation logic had a race condition — mobile clients were hitting refresh twice in parallel on app resume, and whichever request arrived second got a token that had already been rotated.

We added an idempotency key to refresh requests. The 2 am pages stopped.

Algorithm Confusion and Why We Stopped Trusting Defaults

In 2022, there was a well-documented class of attacks where the jwt gem would accept none as an algorithm if the decode call did not explicitly specify one.

The gem fixed this in 2.x.

Explicitly declaring the algorithm is still worth doing regardless:

JWT.decode(
  token,
  Rails.application.secret_key_base,
  true,
  algorithms: ['HS256']
)

We moved from HS256 to RS256 for our public-facing API in 2024.

HS256 uses a shared secret — anyone who can sign tokens can also verify them, which is fine until you have multiple services.

RS256 uses a private key to sign and a public key to verify. The private key lives in exactly one place.

The Claims That Were Missing From Every Tutorial We Read

Most JWT authentication Rails guides validate exp. Almost none validate nbf or aud.

nbf (not before) prevents tokens from being used before their intended activation time.

aud (audience) prevents a token issued for your mobile API from being replayed against your admin API.

JWT.decode(token, public_key, true, {
  algorithms: ['RS256'],
  aud: 'mobile-api',
  verify_aud: true
})

We added audience validation after a penetration test found that admin tokens were validating correctly on user endpoints.

Not exploitable with our auth logic at the time, but the finding was uncomfortable enough to fix that sprint.

Secrets Management in 2026

Rails.application.secret_key_base is not a secrets management strategy.

For HS256, rotating the signing secret invalidates every active token immediately — a forced logout for every user.

We moved JWT signing to a dedicated secret in Rails credentials, separate from secret_key_base, so we could rotate it independently.

For RS256, the private key needs to live somewhere that is not the repository and not a .env file on a developer's laptop. We use AWS Secrets Manager in production.

Check your git history. Not the current state of the file — the full history.

What We Still Have Not Shipped

Token anomaly detection has been on the backlog since 2024.

The idea was to track typical request patterns per user_id and flag tokens that suddenly start making requests from a new continent at 3 am, we built the logging infrastructure for it. We never shipped the detection layer.

The incident that would have been caught by it — we found out through a user email three weeks after the fact.

Rails API JWT Security in 2026 Is Not Optional

Four years ago, you could ship a Rails API with a 30-day HS256 token, and nobody looked twice.

API protection requirements have shifted.

OWASP's API Security Top 10 has had broken authentication in the top three since 2019. Rails API security 2026 expectations from enterprise buyers now include token rotation, audit logging, and documented revocation procedures.

The groundwork is not complicated. Short-lived access tokens paired with a Redis-backed revocation list, RS256 if you have multiple services, and explicit claim validation for aud and iss.

None of this requires a dedicated security team — it requires one focused sprint and the discipline to add the cleanup job before the slow query alert forces your hand.

Token security is the boring work that protects everything else.

Drop your JWT war stories in the comments — especially the ones where the attack vector looked fine at code review.

Edited for Write Catalyst by Wandering Mind