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.
Four lines and you have a magic link
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.
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
- Rescue
ActiveSupport::MessageVerifier::InvalidSignature, or skip the bang version entirely and branch onnillike 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.
Comments
No comments yet. Be the first.