Ruby on Rails Module 6 – Authentication and authorization
Sign-up, email verification and settings
Add sign-up to the Rails 8 authentication generator: a rate-limited registration, signed email verification links, password changes, export and deletion.
What you will learn
- Add a rate-limited sign-up on top of the generated authentication
- Explain what a token from generates_token_for carries and when it stops working
- Verify a new email address through unconfirmed_email and a signed link
- Protect a password change with password_challenge and end the other sessions
- Export an account's data and delete the account in a job
Before you start
What you’ll learn · Before you start
What you will learn
- Add a rate-limited sign-up on top of the generated authentication
- Explain what a token from generates_token_for carries and when it stops working
- Verify a new email address through unconfirmed_email and a signed link
- Protect a password change with password_challenge and end the other sessions
- Export an account's data and delete the account in a job
Before you start
On this page
bin/rails generate authentication signs people in, signs them out and resets a forgotten password. What it cannot
do is let a new teacher join Kaksha: there is no sign-up form, no way to prove that an email address exists, and no
settings page for changing a password or an address. That half of accounts is yours to write, on top of what
the authentication generator left you, and it is where the
mistakes live, because every one of these forms changes a credential.
This lesson builds that half on recorded runs: a registration that visitors may reach and that is counted, an email verification link that carries its own meaning instead of a database row, an address change that cannot lock anyone out, a password change that asks for the current password, and a deletion that hands the data back before it goes. The four programs in this lesson’s folder drive the models directly, so every rule below is a line some run printed.
Sign-up is three posts, and only the first is yours to write
A new teacher joining Kaksha sends three posts, not one: the sign-up form, the verification link they open from their inbox, and the sign-in form. The generated authentication already owns the third one. What you add is a registrations controller for the first, and a tiny controller for the second.
Two lines make the first controller different from every other one in a generated app. allow_unauthenticated_access
is needed because the Authentication concern requires a session on every action, and nobody signing up has one yet.
A rate limit: A filter that counts the requests of one client against a key in a cache store and refuses them once the count rises above the number allowed in the window, by default with a 429 response. More in “Filters, errors and HTTP features” is needed because a sign-up form writes rows and sends mail for anyone who
asks: rate_limit to: 5, within: 10.minutes turns the sixth attempt from one address in ten minutes into a 429, and
the count lives in whichever cache store the app configures for the controller.
The program below is that flow without the HTTP around it: a Registration object saves the user and mints the link,
and the three steps run in order against the models the controllers wrap.
# The three steps of sign-up, driven against the models the controllers wrap:
# create the account and mint a verification token, spend the token, then sign
# in. Standalone Active Record with an in-memory SQLite database; no server,
# no network, no mail delivery (the outbox below stands in for the mailer).
require "active_record"
require "active_support/core_ext/integer/time"
require "active_support/messages/metadata"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
# In an app this verifier is built from secret_key_base; a standalone script sets it.
ActiveSupport::Messages::Metadata.use_message_serializer_for_metadata = true
ActiveRecord::Base.generated_token_verifier = ActiveSupport::MessageVerifier.new(
"kaksha-example-signing-key", digest: "SHA256", serializer: :json_allow_marshal, url_safe: true
)
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.datetime :verified_at
t.timestamps
end
add_index :users, :email_address, unique: true
create_table :sessions do |t|
t.references :user, null: false
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
class User < ActiveRecord::Base
has_many :sessions, dependent: :destroy
has_secure_password
normalizes :email_address, with: ->(value) { value.to_s.strip.downcase }
validates :email_address, presence: true, format: { with: /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/ },
uniqueness: true
validates :password, length: { minimum: 12 }, if: -> { password.present? }
generates_token_for :email_verification, expires_in: 2.days do
email_address
end
def verified? = verified_at.present?
end
class Session < ActiveRecord::Base
belongs_to :user
end
# What RegistrationsController#create does, without the HTTP around it.
class Registration
attr_reader :user
def initialize(attributes)
@user = User.new(attributes)
end
def save
return false unless user.save
OUTBOX << { to: user.email_address, link: "/email_verification/#{user.generate_token_for(:email_verification)}" }
true
end
def errors = user.errors.full_messages
end
OUTBOX = []
SIGNED_UP = { email_address: " Asha.Verma@Example.com ", password: "monsoon-ledger-7", password_confirmation: "monsoon-ledger-7" }
puts "== Step 1: the sign-up form posts once =="
first = Registration.new(SIGNED_UP)
puts " saved? #{first.save}"
asha = first.user
puts " stored email #{asha.email_address.inspect}"
puts " verified? #{asha.verified?}"
puts " mail queued #{OUTBOX.size} to #{OUTBOX.first[:to].inspect}"
puts " link #{OUTBOX.first[:link][0, 46]}…"
puts "\n== The same form posted with bad input =="
[
["mistyped confirmation", { email_address: "rahul@example.com", password: "monsoon-ledger-7", password_confirmation: "monsoon-ledger-8" }],
["password too short", { email_address: "rahul@example.com", password: "kaksha", password_confirmation: "kaksha" }],
["an address already signed up", { email_address: "ASHA.VERMA@example.com", password: "monsoon-ledger-7", password_confirmation: "monsoon-ledger-7" }],
].each do |label, attributes|
attempt = Registration.new(attributes)
puts " #{label}: saved? #{attempt.save} -> #{attempt.errors.join('; ')}"
end
puts " users in the table: #{User.count}"
puts "\n== Step 2: the link in the mail is spent =="
token = OUTBOX.first[:link].split("/").last
found = User.find_by_token_for(:email_verification, token)
puts " token resolves to #{found&.email_address.inspect}"
found.update!(verified_at: Time.current)
puts " verified? #{found.verified?}"
puts " a tampered token resolves to #{User.find_by_token_for(:email_verification, token.sub(/.\z/, 'x')).inspect}"
puts "\n== Step 3: the sign-in form posts the same pair =="
signed_in = User.authenticate_by(email_address: "asha.verma@example.com", password: "monsoon-ledger-7")
puts " right password -> #{signed_in&.email_address.inspect}"
puts " wrong password -> #{User.authenticate_by(email_address: 'asha.verma@example.com', password: 'monsoon-ledger-8').inspect}"
puts " unknown address -> #{User.authenticate_by(email_address: 'nobody@example.com', password: 'monsoon-ledger-7').inspect}"
session = signed_in.sessions.create!(ip_address: "203.0.113.9", user_agent: "Mozilla/5.0 (Android 16; Mobile)")
puts " session rows for Asha: #{signed_in.sessions.count} (id #{session.id}, from #{session.ip_address})"
puts "\n== The gate an unverified account meets =="
rahul = User.create!(email_address: "rahul@example.com", password: "monsoon-ledger-9", password_confirmation: "monsoon-ledger-9")
[signed_in, rahul].each do |user|
allowed = user.verified? ? "may enrol students" : "may read only, until the address is verified"
puts " #{user.email_address}: verified? #{user.verified?} -> #{allowed}"
end Output
== Step 1: the sign-up form posts once == saved? true stored email "asha.verma@example.com" verified? false mail queued 1 to "asha.verma@example.com" link /email_verification/eyJfcmFpbHMiOnsiZGF0YSI6Wz… == The same form posted with bad input == mistyped confirmation: saved? false -> Password confirmation doesn't match Password password too short: saved? false -> Password is too short (minimum is 12 characters) an address already signed up: saved? false -> Email address has already been taken users in the table: 1 == Step 2: the link in the mail is spent == token resolves to "asha.verma@example.com" verified? true a tampered token resolves to nil == Step 3: the sign-in form posts the same pair == right password -> "asha.verma@example.com" wrong password -> nil unknown address -> nil session rows for Asha: 1 (id 1, from 203.0.113.9) == The gate an unverified account meets == asha.verma@example.com: verified? true -> may enrol students rahul@example.com: verified? false -> may read only, until the address is verified
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby registration_flow.rb
The first save stored asha.verma@example.com, lower-cased and trimmed by normalizes before the uniqueness rule
read it, with verified_at still empty. Three bad forms then changed nothing: a mistyped confirmation, a
six-character password and an address that had signed up already, each with its own message and save returning
false. The users table still held one row. Follow the posts through the controllers:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A visitor fills the form. The form posts an email address and a password to a controller nobody is signed in to reach. In the generated authentication that needs one line, allow_unauthenticated_access, because every other action requires a session.
- The post is counted first. rate_limit to: 5, within: 10.minutes runs as a before_action, so the fifth attempt from one address in ten minutes is the last one the controller will answer. The count lives in the cache store the app configures.
- Bad input never reaches a row. The recorded run posted three bad forms: a mistyped confirmation, a six-character password and an address that had signed up already. Each save returned false with its message, and the users table still held one row.
- A good form writes one row. The address is normalised to lower case before the uniqueness rule reads it, the password is hashed by has_secure_password, and the row is saved with verified_at still empty. Nothing about the account is trusted yet.
- One link goes out by mail. generate_token_for(:email_verification) signs the row id together with the address and a two-day expiry, and the mail carries that string in a URL. Nothing is stored for it: the token is the record.
- Opening it proves the address. find_by_token_for(:email_verification, token) checks the signature, the expiry and the address inside the token against the row it names, then returns the user. The action writes verified_at and the account is trusted.
- The last post is ordinary. Signing in is the generated SessionsController: User.authenticate_by with the same pair, a sessions row for this browser, and a signed session_id cookie. The recorded run got the user for the right password and nil for the other two tries.
Two choices in that flow are yours, and both are product decisions rather than Rails ones. The first is what an
account may do before its address is proven: Asha could read but not enrol students until verified_at was written,
which the run shows as two different answers for the same question. The second is whether you check a new password
against a list of passwords known to have leaked. That check means a request to somebody else’s service on your
sign-up path, so it belongs in a design discussion with its own failure plan, and it is not built here.
Checkpoint
Check: Sign-up as three posts
A verification link is a signed payload, not a row
The usual shape for a verification link is a stored token: a random string in a column, looked up when the link is opened and deleted afterwards. Rails 8 offers another. generates_token_for: Defines a signed, purpose-scoped token that expires and is tied to chosen record data, so the token stops working when that data, such as the password salt, changes. More in “The Rails 8 authentication generator” signs the record’s id, together with whatever its block returns and an expiry, into a string you put in a URL. Nothing is written, nothing has to be swept up, and the token still stops working at the right moments.
The declaration is three lines on the model, and the block is the interesting one:
generates_token_for :email_verification, expires_in: 2.days with a block that returns email_address. The run
below mints one token, decodes it, and then kills it in each of the ways that matter.
# What a token from generates_token_for really is, and the three ways it stops
# working: the value in its block changes, its window runs out, or a byte of it
# is altered. The run also decodes one token, because what the block returns
# travels inside it as readable JSON. Standalone Active Record, in-memory
# SQLite, no server and no network; the clock is moved with travel_to.
require "active_record"
require "active_support/core_ext/integer/time"
require "active_support/messages/metadata"
require "active_support/testing/time_helpers"
require "base64"
require "json"
include ActiveSupport::Testing::TimeHelpers
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
# An app builds this verifier from secret_key_base, with the serializer and the
# metadata setting that config.load_defaults 8.1 chooses; a standalone script
# sets the same three things by hand.
ActiveSupport::Messages::Metadata.use_message_serializer_for_metadata = true
ActiveRecord::Base.generated_token_verifier = ActiveSupport::MessageVerifier.new(
"kaksha-example-signing-key", digest: "SHA256", serializer: :json_allow_marshal, url_safe: true
)
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
end
class User < ActiveRecord::Base
has_secure_password
normalizes :email_address, with: ->(value) { value.to_s.strip.downcase }
generates_token_for :email_verification, expires_in: 2.days do
email_address
end
end
asha = User.create!(email_address: "old@example.com", password: "monsoon-ledger-7")
travel_to Time.utc(2031, 5, 10, 9, 0, 0)
token = asha.generate_token_for(:email_verification)
puts "== One token, minted for old@example.com =="
puts " length #{token.length} characters, dots: #{token.count('.')}"
payload, signature = token.split("--")
puts " the part before the dashes decodes to:"
puts " #{JSON.parse(Base64.urlsafe_decode64(payload)).to_json}"
puts " the part after them is the signature: #{signature[0, 16]}… (#{signature.length} characters)"
puts " resolves to #{User.find_by_token_for(:email_verification, token)&.email_address.inspect}"
puts "\n== The email changes, and the token dies with it =="
asha.update!(email_address: "new@example.com")
puts " row now holds #{asha.reload.email_address.inspect}"
puts " the old token resolves to #{User.find_by_token_for(:email_verification, token).inspect}"
fresh = asha.generate_token_for(:email_verification)
puts " a token minted now resolves to #{User.find_by_token_for(:email_verification, fresh)&.email_address.inspect}"
puts "\n== The two-day window =="
[
["1 day, 23 hours later", 1.day + 23.hours],
["2 days and a minute later", 2.days + 1.minute],
].each do |label, ahead|
travel_to Time.utc(2031, 5, 10, 9, 0, 0) + ahead
puts " #{label}: #{User.find_by_token_for(:email_verification, fresh)&.email_address.inspect}"
end
puts "\n== What the bang version does with a dead token =="
begin
User.find_by_token_for!(:email_verification, fresh)
rescue => error
puts " find_by_token_for! raised #{error.class}"
end
puts " find_by_token_for returned #{User.find_by_token_for(:email_verification, fresh).inspect}"
puts "\n== A byte changed in transit =="
travel_to Time.utc(2031, 5, 10, 9, 30, 0)
inside_window = asha.generate_token_for(:email_verification)
altered = inside_window.sub(/.\z/) { |char| char == "A" ? "B" : "A" }
puts " altered token resolves to #{User.find_by_token_for(:email_verification, altered).inspect}"
puts " untouched token resolves to #{User.find_by_token_for(:email_verification, inside_window)&.email_address.inspect}"
travel_back Output
== One token, minted for old@example.com ==
length 220 characters, dots: 0
the part before the dashes decodes to:
{"_rails":{"data":[1,"old@example.com"],"exp":"2031-05-12T09:00:00.000Z","pur":"User\nemail_verification\n172800"}}
the part after them is the signature: 1e161333c9aefcc7… (64 characters)
resolves to "old@example.com"
== The email changes, and the token dies with it ==
row now holds "new@example.com"
the old token resolves to nil
a token minted now resolves to "new@example.com"
== The two-day window ==
1 day, 23 hours later: "new@example.com"
2 days and a minute later: nil
== What the bang version does with a dead token ==
find_by_token_for! raised ActiveSupport::MessageVerifier::InvalidSignature
find_by_token_for returned nil
== A byte changed in transit ==
altered token resolves to nil
untouched token resolves to "new@example.com"
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby email_change_tokens.rb
The decoded payload is worth reading twice: {"data":[1,"old@example.com"],"exp":"2031-05-12T09:00:00.000Z","pur":"User\nemail_verification\n172800"},
and a 64-character signature after the dashes. The id and the block’s value travel in the open as readable JSON, so a
block must never return anything you would not put in a URL. The expiry is there as a date, and the window is in the
purpose line as well, in seconds: change expires_in in the model and every link already sitting in somebody’s inbox
stops resolving, because the purpose it was signed with no longer exists. Walk through the one token the run minted,
from the moment it was signed to each of the four ways it stopped working:
- Minted
220 characters, signed, readable
The run decoded the token: the row id, the address the block returned, an expiry two days ahead and a purpose string that ends in 172800, the window in seconds. A signature of 64 characters follows the dashes.
- Any time inside
The lookup returns the user
find_by_token_for rebuilds the payload from the row it names and compares it with the payload in the token. At 1 day and 23 hours the run still got the user back, with no row anywhere recording the link.
- On a new address
Dead the moment the row changes the block decides
The block returns the email address, so saving a different address makes the rebuilt payload differ from the signed one. The run minted a token for the old address, changed the row, and the lookup answered nil inside the window.
- Window over
Refused on the expiry alone
Two days and one minute after minting, the same token resolved to nil. The window is part of the signed purpose, so raising or lowering expires_in in the model invalidates every link already in somebody's inbox.
- A byte altered
The signature refuses it security
Changing the last character of a token gave nil as well. There is nothing to guess: without the signing key no payload can be re-signed, which is why the token can travel in a URL at all.
- Either way
nil, or a raise, as you ask
find_by_token_for returns nil for every one of these cases; find_by_token_for! raised ActiveSupport::MessageVerifier::InvalidSignature in the run. A controller that wants one page for all of them rescues that class.
The timeline has 6 events, from Minted to Either way.
find_by_token_for rebuilds the payload from the row the token names and compares it with the signed one, which is
why the run’s old token returned nil the moment the address changed, inside the two-day window. At two days and one
minute, a valid token for an unchanged address returned nil too. The bang version, find_by_token_for!, raised
ActiveSupport::MessageVerifier::InvalidSignature for the same token, so a controller that wants one page for every
dead link rescues that class instead of writing four branches.
Key idea
A token from generates_token_for is self-describing: the signature makes it unforgeable, the expiry closes the
window, and the block’s value ties it to the state it was minted for, so no column and no clean-up job are needed.
Checkpoint
Check: What a signed link carries
Changing the sign-in address without locking anyone out
The address is also the login, so changing it is the most dangerous settings form in the app. Write it in one update and you have built two bugs: the person may lose their way in, because the new address can be a typo nobody can read mail at, and whoever sits down at a browser left signed in can move the account to an address of their own.
The fix is a second column. unconfirmed_email: A column that holds the email address a user has asked to move to, while email_address keeps signing them in, until a link sent to the new address is opened. holds the claim while
email_address keeps signing people in, and the claim becomes the login only when a link sent to the new address
comes back. Minting that link from the claim, not from the current address, is what makes one claim replace another:
# Two settings forms that change a credential: the password, which asks for the
# current one through password_challenge, and the email address, which is held
# in unconfirmed_email until a token sent to the new address comes back. Each
# "post" loads the record again, as a request would. Standalone Active Record
# with an in-memory SQLite database; the outbox stands in for the mailer.
require "active_record"
require "active_support/core_ext/integer/time"
require "active_support/messages/metadata"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveSupport::Messages::Metadata.use_message_serializer_for_metadata = true
ActiveRecord::Base.generated_token_verifier = ActiveSupport::MessageVerifier.new(
"kaksha-example-signing-key", digest: "SHA256", serializer: :json_allow_marshal, url_safe: true
)
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :users do |t|
t.string :email_address, null: false
t.string :unconfirmed_email
t.string :password_digest, null: false
t.timestamps
end
create_table :sessions do |t|
t.references :user, null: false
t.string :ip_address
t.timestamps
end
end
OUTBOX = []
class User < ActiveRecord::Base
has_many :sessions, dependent: :destroy
has_secure_password
normalizes :email_address, :unconfirmed_email, with: ->(value) { value.to_s.strip.downcase.presence }
validates :password, length: { minimum: 12 }, if: -> { password.present? }
validates :unconfirmed_email, format: { with: /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/ }, allow_nil: true
# The block returns the address being claimed, so the link dies when the claim changes.
generates_token_for :email_change, expires_in: 2.days do
unconfirmed_email
end
def confirm_email_change!
previous = email_address
update!(email_address: unconfirmed_email, unconfirmed_email: nil)
OUTBOX << { to: previous, subject: "Your Kaksha sign-in address was changed" }
end
end
class Session < ActiveRecord::Base
belongs_to :user
end
ID = User.create!(email_address: "asha.verma@example.com", password: "monsoon-ledger-7").id
2.times { |n| Session.create!(user_id: ID, ip_address: "203.0.113.#{9 + n}") }
# Every form below starts from a freshly loaded record, as a new request does.
def asha = User.find(ID)
puts "== The password form, with the wrong current password =="
user = asha
puts " update returned #{user.update(password: 'chasing-kites-11', password_confirmation: 'chasing-kites-11', password_challenge: 'monsoon-ledger-0')}"
puts " errors: #{user.errors.full_messages.join('; ')}"
puts " the stored digest still accepts the old password: #{!!asha.authenticate('monsoon-ledger-7')}"
puts "\n== The same form, with the current password =="
user = asha
puts " update returned #{user.update(password: 'chasing-kites-11', password_confirmation: 'chasing-kites-11', password_challenge: 'monsoon-ledger-7')}"
puts " old password now: #{!!asha.authenticate('monsoon-ledger-7')}"
puts " new password now: #{!!asha.authenticate('chasing-kites-11')}"
puts " sessions before signing the other browsers out: #{Session.where(user_id: ID).count}"
kept = Session.where(user_id: ID).order(:id).last
Session.where(user_id: ID).where.not(id: kept.id).destroy_all
puts " sessions after, keeping only this one (id #{kept.id}): #{Session.where(user_id: ID).count}"
puts "\n== What a missing field and an empty field do =="
no_field = asha
puts " no password_challenge in the params at all: #{no_field.update(password: 'silent-change-12', password_confirmation: 'silent-change-12')}"
puts " the password really changed: #{!!asha.authenticate('silent-change-12')}"
blank_field = asha
puts " password_challenge sent as an empty string: #{blank_field.update(password: 'chasing-kites-11', password_confirmation: 'chasing-kites-11', password_challenge: '')}"
puts " errors: #{blank_field.errors.full_messages.join('; ')}"
asha.update!(password: "chasing-kites-11", password_confirmation: "chasing-kites-11", password_challenge: "silent-change-12")
puts "\n== The email form writes unconfirmed_email and nothing else =="
user = asha
user.update!(unconfirmed_email: " Asha@Kaksha.example ", password_challenge: "chasing-kites-11")
puts " email_address #{user.email_address.inspect}"
puts " unconfirmed_email #{user.unconfirmed_email.inspect}"
first_link = user.generate_token_for(:email_change)
OUTBOX << { to: user.unconfirmed_email, subject: "Confirm your new address" }
puts " mail went to #{OUTBOX.last[:to].inspect}"
puts "\n== The learner changes their mind and claims a third address =="
asha.update!(unconfirmed_email: "asha.v@kaksha.example", password_challenge: "chasing-kites-11")
puts " the first link resolves to #{User.find_by_token_for(:email_change, first_link).inspect}"
second_link = asha.generate_token_for(:email_change)
puts " the second link resolves to #{User.find_by_token_for(:email_change, second_link)&.unconfirmed_email.inspect}"
puts "\n== Confirming swaps the columns and tells the old address =="
User.find_by_token_for(:email_change, second_link).confirm_email_change!
puts " email_address #{asha.email_address.inspect}"
puts " unconfirmed_email #{asha.unconfirmed_email.inspect}"
puts " the used link now resolves to #{User.find_by_token_for(:email_change, second_link).inspect}"
puts " outbox:"
OUTBOX.each { |mail| puts " to #{mail[:to]}: #{mail[:subject]}" } Output
== The password form, with the wrong current password ==
update returned false
errors: Password challenge is invalid
the stored digest still accepts the old password: true
== The same form, with the current password ==
update returned true
old password now: false
new password now: true
sessions before signing the other browsers out: 2
sessions after, keeping only this one (id 2): 1
== What a missing field and an empty field do ==
no password_challenge in the params at all: true
the password really changed: true
password_challenge sent as an empty string: false
errors: Password challenge is invalid
== The email form writes unconfirmed_email and nothing else ==
email_address "asha.verma@example.com"
unconfirmed_email "asha@kaksha.example"
mail went to "asha@kaksha.example"
== The learner changes their mind and claims a third address ==
the first link resolves to nil
the second link resolves to "asha.v@kaksha.example"
== Confirming swaps the columns and tells the old address ==
email_address "asha.v@kaksha.example"
unconfirmed_email nil
the used link now resolves to nil
outbox:
to asha@kaksha.example: Confirm your new address
to asha.verma@example.com: Your Kaksha sign-in address was changed
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby password_and_email_settings.rb
In the recorded run the two columns held asha.verma@example.com and asha@kaksha.example at the same time, and the
mail went only to the second. When a third address was claimed, the first link resolved to nil while a link minted
after the change worked, because the token’s block returns unconfirmed_email. Confirming wrote the claim into
email_address, cleared the column, and sent a short note to the previous address, which is the only place the real
owner would see a change they did not make. Step through it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The form asks for two things. The settings form takes the new address and the current password. Without the password a borrowed browser could move the account to an attacker's address; the login column itself is not touched by this post.
- Only the claim is written. The update writes unconfirmed_email and nothing else, so the user keeps signing in with the old address while the new one is unproven. The recorded run showed the two columns holding different addresses at this point.
- The link goes to the new address. The token's block returns unconfirmed_email, so the signed link is about that claim. It is sent to the new address only: delivering there is the proof that the person asking can read mail at the address they want.
- A second claim kills the first. The run then claimed a third address. The first link now rebuilds a different payload, so it resolved to nil, while a link minted after the change worked. One claim at a time is the whole rule, and the column enforces it.
- Confirming moves it over. The action loads the user from the token, then writes email_address from unconfirmed_email and clears the claim in one update. From now on the new address is the one that signs in, and the used link resolves to nil.
- The old address hears about it. A short mail to the previous address says the sign-in address was changed. If the change was not the owner's doing, that mail is the only place they will see it, which is why it is sent after the swap, not before.
Signing the link with the address that is already the login
A block that returns email_address mints a link that is valid for the address the person already has, so a second
claim does not invalidate the first and whichever link is opened last wins. Return the claim,
unconfirmed_email, and the model enforces one pending change by itself.
Checkpoint
Check: Address changes through a claim
A password change asks for the current password
A sign-in proves who someone is at that moment; a browser left open proves nothing an hour later. That is what
password_challenge: An attribute that has_secure_password adds: when it is set, the record validates it against the password digest already stored, so a password change can require the current password. is for. has_secure_password adds the attribute to every
model that calls it, and when the attribute is set to anything other than nil, the record validates it against the
password digest that is currently stored, through dirty tracking, before the new one is written.
The same recorded run shows all four cases. The wrong current password refused the update with
Password challenge is invalid and the stored digest still accepted the old password. The right one let the change
through: the old password stopped authenticating, the new one started, and the two session rows could then be reduced
to the one browser doing the changing. An empty string was refused as well, because an empty string is not nil.
And the fourth case is the trap: a form that sends no password_challenge key at all changed the password with
nothing asked, because the validation only runs when the attribute is present. Strong parameters are where you close
that door, by giving the parameter a default so a missing field is read as an empty string and refused.
A challenge field that an attacker can simply delete
params.expect(user: [...]) with no default means removing the field from the request skips the check entirely, as
the run shows. Add a default for it, so an absent field arrives as "" and fails the validation, and the only way
through is the real current password.
The address form deserves the same question, which is why the recorded run asks for the password there too. Rails’ own security guide makes the point in its account-hijacking section: someone who can move the address can then ask for a password reset and have it delivered to themselves, so the address form is as strong a move as the password form and is guarded the same way.
Ending the other sessions is the second half of the change. A password change that leaves every other browser signed in protects nobody: the whole point is to push out whoever you think is there. Destroy the user’s session rows except the current one, as the run does, and say so on the page.
Checkpoint
Check: The current-password challenge
Deleting an account, and handing the data back first
Two settings buttons remain, and both are about leaving. One hands back what the account holds; the other removes it. Neither belongs inside the request: both walk several tables, and a browser waiting on that is a timeout looking for somewhere to happen. The controller’s work is to mark the row and enqueue, which is all the recorded run does before the queue is drained.
# A data export and an account deletion, both done by a job rather than inside
# the request: the controller only enqueues, and the rows change when the job
# runs. The deletion keeps the fee receipts an accounts team must keep and
# removes the person from them. Standalone Active Record and Active Job with
# the test queue adapter; in-memory SQLite, no server, no network.
require "active_record"
require "active_job"
require "json"
require "logger"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveJob::Base.queue_adapter = :test
ActiveJob::Base.logger = Logger.new(IO::NULL)
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.datetime :deletion_requested_at
t.timestamps
end
create_table :sessions do |t|
t.references :user, null: false
t.string :ip_address
t.timestamps
end
create_table :enrolments do |t|
t.references :user, null: false
t.string :batch_name
t.timestamps
end
create_table :receipts do |t|
t.references :user, null: true
t.string :number, null: false
t.string :payer_name
t.integer :amount_paise, null: false
t.timestamps
end
end
class User < ActiveRecord::Base
has_many :sessions, dependent: :destroy
has_many :enrolments, dependent: :destroy
has_many :receipts, dependent: :nullify
has_secure_password
end
class Session < ActiveRecord::Base; belongs_to :user; end
class Enrolment < ActiveRecord::Base; belongs_to :user; end
class Receipt < ActiveRecord::Base; belongs_to :user, optional: true; end
DOWNLOADS = {}
class ExportAccountJob < ActiveJob::Base
def perform(user_id)
user = User.find(user_id)
DOWNLOADS[user_id] = {
account: { email_address: user.email_address, joined_on: "2031-02-04" },
sessions: user.sessions.order(:id).map { |s| { ip_address: s.ip_address } },
enrolments: user.enrolments.order(:id).map { |e| { batch: e.batch_name } },
receipts: user.receipts.order(:id).map { |r| { number: r.number, amount_in_rupees: r.amount_paise / 100 } },
}
end
end
class DeleteAccountJob < ActiveJob::Base
def perform(user_id)
user = User.find_by(id: user_id)
return unless user # the job may run twice; the second run has nothing to do
user.receipts.update_all(payer_name: "Deleted account")
user.destroy!
end
end
asha = User.create!(email_address: "asha.verma@example.com", password: "monsoon-ledger-7")
2.times { |n| asha.sessions.create!(ip_address: "203.0.113.#{9 + n}") }
asha.enrolments.create!(batch_name: "Class 10 maths, morning")
asha.enrolments.create!(batch_name: "Class 10 science, evening")
asha.receipts.create!(number: "KK-2031-0041", payer_name: "Asha Verma", amount_paise: 450_000)
asha.receipts.create!(number: "KK-2031-0098", payer_name: "Asha Verma", amount_paise: 450_000)
def queue = ActiveJob::Base.queue_adapter
def run_queue!
jobs = queue.enqueued_jobs.dup
queue.enqueued_jobs.clear
jobs.each { |job| ActiveJob::Base.execute(job.except(:job).merge("job_class" => job[:job].to_s)) }
jobs.size
end
puts "== The export request only enqueues =="
ExportAccountJob.perform_later(asha.id)
puts " jobs waiting: #{queue.enqueued_jobs.size} (#{queue.enqueued_jobs.first[:job]})"
puts " download ready for the user? #{DOWNLOADS.key?(asha.id)}"
puts " jobs run now: #{run_queue!}"
puts " download ready for the user? #{DOWNLOADS.key?(asha.id)}"
puts "\n what the file holds:"
puts JSON.pretty_generate(DOWNLOADS[asha.id]).lines.map { |line| " #{line}" }.join
puts "\n== The deletion request marks the row and enqueues =="
asha.update!(deletion_requested_at: Time.utc(2031, 5, 10, 9, 0, 0))
DeleteAccountJob.perform_later(asha.id)
puts " deletion_requested_at #{asha.deletion_requested_at.iso8601}"
puts " user rows #{User.count}, sessions #{Session.count}, enrolments #{Enrolment.count}, receipts #{Receipt.count}"
puts "\n== When the job runs =="
run_queue!
puts " user rows #{User.count}, sessions #{Session.count}, enrolments #{Enrolment.count}, receipts #{Receipt.count}"
Receipt.order(:id).each do |receipt|
puts " receipt #{receipt.number}: user_id #{receipt.user_id.inspect}, payer #{receipt.payer_name.inspect}, ₹#{receipt.amount_paise / 100}"
end
puts "\n== The same job delivered twice =="
DeleteAccountJob.perform_later(asha.id)
puts " jobs run: #{run_queue!}; user rows #{User.count}, receipts #{Receipt.count}" Output
== The export request only enqueues ==
jobs waiting: 1 (ExportAccountJob)
download ready for the user? false
jobs run now: 1
download ready for the user? true
what the file holds:
{
"account": {
"email_address": "asha.verma@example.com",
"joined_on": "2031-02-04"
},
"sessions": [
{
"ip_address": "203.0.113.9"
},
{
"ip_address": "203.0.113.10"
}
],
"enrolments": [
{
"batch": "Class 10 maths, morning"
},
{
"batch": "Class 10 science, evening"
}
],
"receipts": [
{
"number": "KK-2031-0041",
"amount_in_rupees": 4500
},
{
"number": "KK-2031-0098",
"amount_in_rupees": 4500
}
]
}
== The deletion request marks the row and enqueues ==
deletion_requested_at 2031-05-10T09:00:00Z
user rows 1, sessions 2, enrolments 2, receipts 2
== When the job runs ==
user rows 0, sessions 0, enrolments 0, receipts 2
receipt KK-2031-0041: user_id nil, payer "Deleted account", ₹4500
receipt KK-2031-0098: user_id nil, payer "Deleted account", ₹4500
== The same job delivered twice ==
jobs run: 1; user rows 0, receipts 2
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby export_and_delete.rb
The export job wrote a JSON file with the account, its two sessions, its two enrolments and its two receipts; before
it ran, nothing was ready to download. The deletion job then destroyed the user, both sessions and both enrolments,
and left the two receipts standing with user_id set to nil and the payer name replaced. That is the shape of the
decision every deletion needs: which rows go, which rows stay because an accounts team must keep them, and what is
removed from the rows that stay so they no longer name a person.
The job also runs twice in the recorded output, because a queue that promises at-least-once delivery will eventually deliver one twice. The second run found no user and did nothing, which is the whole of making it safe to repeat.
One Indian law in the background
India’s Digital Personal Data Protection Act, 2023 gives a person a right to a summary of the personal data a service processes (section 11) and a right to correction and erasure (section 12), with erasure refused where the data must be kept for the stated purpose or by another law. That is close to the export and the kept receipts above, which is why the two buttons are worth building before anyone asks for them. This is information, not legal advice.
Checkpoint
Check: Export and deletion in a job
Practise: the three posts and the two settings forms
Work in copies of this lesson’s programs; each is a single file with its own in-memory database, so a run after every step tells you at once whether the rule held. Run the file after each task and read what changed.
- In a copy of
registration_flow.rb, make the verification link single-use: have the token’s block return both the address and whether the account is verified, then check that the link that verified the account resolves tonilwhen it is opened a second time. - In the same copy, refuse a sign-in until the address is proven. Write a method that returns the user only when
verified_atis present, and print what it answers for Asha before and after the link is opened. - In a copy of
password_and_email_settings.rb, add a second claim rule: an address already used by another account must be refused when it is claimed, not when it is confirmed. Prove it with a second user who owns that address. - In the same copy, record the change. Add a
credential_eventstable and write one row for each accepted password change and each confirmed address change, with what changed and when, then print the two rows. - In a copy of
export_and_delete.rb, make the export job safe to run twice and the deletion job keep one more table: give receipts aguardian_nameand clear it too, then run the deletion job twice and count the rows.
The questions below use these runs: the normalised address and the three refusals, the decoded token, the two columns during an address change, the four challenge cases and the rows the deletion left behind. A few of them move the same rule to a table this lesson never built.
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: why mint an email-verification link with generates_token_for :email_verification, expires_in: 2.days and a block that returns email_address, instead of storing a random token in a column?
Because all three things a verification link needs are already in the string. The signature makes it unforgeable, so nothing has to be looked up to know the link is genuine; the signed expiry closes the window without a clean-up job for stale rows; and the block’s value ties the link to the state it was minted for, so the link dies the moment the address changes, which is exactly when it should. A random token in a column gives you none of that for nothing: you add a column, an index, a lookup, an expiry comparison and a job to sweep what nobody opened, and you still have to remember to delete the row when the address changes. The cost of the Rails shape is that the id and the block’s value travel in the open as readable JSON, so the block returns only what you are happy to see in a URL, and a token cannot be revoked one by one; you change what the block returns, and every link for that record goes at once.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Kaksha sends three one-time links: an email verification, a password reset, and an invitation to a teacher who has no account yet. For each, choose a signed token from generates_token_for or a row in a table, and defend the choice against the option you did not take.
-
Mid to senior
A guardian asks for a copy of their data, then asks for the account to be deleted. The export reads nine tables and comes to about 40 MB; the deletion touches fee receipts the accounts team must keep for years. Design both as jobs, and say how you would show each one finished.
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Sign-up takes three posts. Which one is new work, and what must it have?
The registration. It needs allow_unauthenticated_access, since nobody has a session yet, and a rate limit, because it writes rows and sends mail for anyone who asks.
-
Why does a verification link made with generates_token_for need no database column?
The token carries the record id, the block's value and the expiry as signed JSON that can be read but not forged. It stops working once the signature, the time window or the block's value no longer matches.
-
How do you change a user's sign-in address without locking them out?
Store the new address in unconfirmed_email and keep signing in with the old one. Swap the columns only when a link sent to the new address comes back; minting that link from the claim makes a newer claim kill the older link.
-
How does password_challenge make a password change ask for the current password, and what is the trap?
It checks the given value against the stored digest, but only when the attribute is present, so strong parameters must give it a default. The change should also end the user's other sessions.
-
How should an account export or deletion be carried out?
The request only enqueues it, and a job that is safe to run twice does the work. A deletion decides which rows stay for the accounts team and what is removed from them.
Key takeaways
- Sign-up is three posts, and only the registration is new work: it needs
allow_unauthenticated_accessbecause nobody has a session yet, and a rate limit because it writes rows and sends mail for anyone who asks. - A token from
generates_token_forcarries the id, the block’s value and the expiry as signed, readable JSON, so it needs no column and stops working when the signature, the window or the block’s value fails to match. - An address change writes
unconfirmed_emailfirst and swaps the columns only when a link sent to the new address comes back, with the link minted from the claim so a newer claim kills the older link. password_challengevalidates the current password against the stored digest, but only when the attribute is present, so strong parameters must give it a default, and the change should end the other sessions.- An export and a deletion are enqueued by the request and done by a job that can run twice, and a deletion decides which rows stay for the accounts team and what is removed from them.
Terms in this lesson
- password_challenge
- An attribute that has_secure_password adds: when it is set, the record validates it against the password digest already stored, so a password change can require the current password.
- unconfirmed_email
- A column that holds the email address a user has asked to move to, while email_address keeps signing them in, until a link sent to the new address is opened.
References
- ActiveRecord::TokenFor::ClassMethods (Ruby on Rails)
- ActiveModel::SecurePassword::ClassMethods (Ruby on Rails)
- ActionController::RateLimiting::ClassMethods (Ruby on Rails)
- Securing Rails Applications (Ruby on Rails)
- Active Job Basics (Ruby on Rails)
- The Digital Personal Data Protection Act, 2023 (Ministry of Electronics and Information Technology, Government of India)
Report a problem with this lesson
Kept only in this browser. Your Learn progress