LaunchKit
← All posts
· 6 min read · by The LaunchKit team · 5 views

Your Rails magic link doesn't expire when it's used

You've wired up a magic link, it works, and you're about to ship it. Here's the thing that'll bite you three weeks later: that link still signs people in after they've used it. Not once more. As many times as anyone likes, for the whole expiry window.

We'll build it, measure exactly how leaky it is, and fix it with one line.

generates_token_for landed in Rails 7.1 and it's the whole feature. You don't need a gem, a tokens table, or a background job to clean expired rows up.

class User < ApplicationRecord
  generates_token_for :magic_link, expires_in: 15.minutes do
    email
  end
end

That gives you user.generate_token_for(:magic_link) to put in an email, and User.find_by_token_for(:magic_link, params[:token]) to turn it back into a user. The token carries the user id, the purpose and the expiry inside itself, signed with your secret_key_base, so there's nothing to look up and nothing to store. Everything below was measured on Rails 8.1.4 against a rails new --minimal app.

Two magic links for the same user drawn side by side. On the left the block returns email, so the token is unchanged by signing in and the same string is accepted on the first, second and third visit. On the right the block returns magic_link_used_at, so the sign-in writes that column, the signed payload no longer matches and the second visit resolves to nil. The only difference between the two columns is the line inside the block.

It isn't single use, and it isn't random

Two measurements, and the second one surprises people more than the first.

token = user.generate_token_for(:magic_link)
3.times { User.find_by_token_for(:magic_link, token) }
# still finds the user  => true

token == user.generate_token_for(:magic_link)
# => true

So one link signs you in three times, and calling the generator twice hands back the same 182 character string rather than a fresh one. That second result is the one that explains the first. This isn't a secret Rails stored and can hand out once. It's a signed message, and the signature is a pure function of the user id, the purpose, the expiry and whatever your block returned. Same inputs, same bytes, every time.

Which means "mark it used" has nowhere to write. There's no row. If you want a link to stop working, you have to change one of the inputs, and the only input you control is the block.

Worth knowing before this reaches real inboxes: a magic link that stays live for fifteen minutes is a magic link that a forwarded email, a shared screen or a corporate mail scanner prefetching URLs can all replay. Plenty of teams decide that's an acceptable window. Decide it on purpose rather than by accident.

The block is the whole mechanism

Our block returned email, so let's see what that actually bought us.

token = user.generate_token_for(:magic_link)

user.update!(confirmed_at: Time.current)
User.find_by_token_for(:magic_link, token)   # => still the user

user.update!(email: "ada@newmail.test")
User.find_by_token_for(:magic_link, token)   # => nil

An unrelated column moved and the token didn't care. The block's own value moved and the token died. That's the rule, and it's the only rule: Rails re-runs your block when it verifies, compares the result against what's baked into the signature, and rejects the token if they differ.

This is also why the password_reset example in Rails' own documentation behaves the way everyone expects. It reads password_salt&.last(10), and the salt changes the instant a password does, so resetting your password invalidates the reset link that got you there. We measured it: fresh token found the user, then one update!(password: ...) and the same token returned nil. Nobody wrote any burning logic. The block just happens to read something the action changes.

Your magic link doesn't change anything when it's used, so there's nothing for the block to notice.

Make it burn

Give the block something your sign-in writes.

class User < ApplicationRecord
  generates_token_for :single_use_link, expires_in: 15.minutes do
    magic_link_used_at&.to_i
  end
end

Add the column, stamp it when the link is redeemed, and that's the feature:

def show
  user = User.find_by_token_for(:single_use_link, params[:token])
  return redirect_to(new_session_path, alert: "That link has expired.") unless user

  user.update!(magic_link_used_at: Time.current)
  start_session_for(user)
  redirect_to root_path
end

Measured, in order: the token found the user, the controller stamped the column, and the same token came back nil on the next attempt. Issuing a new link after that works fine, because the new token is signed against the new timestamp.

Two details worth getting right. Use &.to_i rather than the raw timestamp, because a Time serialises with more precision than you want in a signature and you'll chase a token that stops matching itself after a round trip through the database. And stamp the column after you've found the user and before you establish the session, so a crash in the middle leaves the link dead rather than live.

Expiry fails in a way you probably aren't rescuing

The clock works exactly as advertised. On a 15 minute token we found the user at 14 minutes and got nil at 16. The interesting part is the bang version:

User.find_by_token_for!(:magic_link, expired_token)
# ActiveSupport::MessageVerifier::InvalidSignature

Not ActiveRecord::RecordNotFound. If your controller rescues RecordNotFound to render a friendly "link expired" page, an expired magic link sails straight past it and your user gets a

  1. Rescue ActiveSupport::MessageVerifier::InvalidSignature, or skip the bang version entirely and branch on nil like the controller above does.

The same exception is what a tampered or truncated token raises, which is convenient: one rescue covers expired, edited and garbage input, and none of them should tell the visitor which one it was.

The purpose with no block

You can leave the block off:

generates_token_for :naked, expires_in: 15.minutes

We changed the user's email and the token kept working. Nothing invalidates it except the clock, which makes it the right shape for something genuinely stateless and the wrong shape for sign-in.

Purposes don't leak into each other, which is the one bit of safety you get for free. A token minted for :naked returns nil from find_by_token_for(:password_reset, token). So a token that unsubscribes somebody from an email can't be replayed against your session endpoint, as long as you give every use its own purpose.

What this page doesn't cover

We never sent an email. Everything above ran in a model test, so there's nothing here about deliverability, about link prefetching by Outlook and friends actually consuming your token in the wild, or about what a mail client does to a 182 character query parameter.

Rate limiting isn't here either, and a magic link endpoint wants it: the request that issues a link is an unauthenticated POST with an email address in it, which is both an email bomb and a user enumeration oracle if you let it answer differently for addresses that exist. rate_limit has been in Rails since 8.0 and that's where it goes.

We also didn't touch secret_key_base rotation. Every token above is signed with it, so rotating it invalidates every outstanding link at once, and whether that's a problem depends on how long your expiry window is.

#rails #authentication #security

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.