Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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.

Sign up, open the link, sign in: the three posts as model calls Ruby · registration_flow.rb
# 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:

Interactive · stage by stage The three posts that turn a visitor into a signed-in user Step through one sign-up. The branch on the left is where a refused form goes back to the visitor.
The three posts that turn a visitor into a signed-in user, step 7 of 7. Showing Sign-up form (not signed in), The create action (save was false), A users row (one row, unverified), Mail with a link (a link, no row), The link opened (verified_at written), Sign-in form (row 1, cookie set). In focus: Sign-in form.errors: render :newSign-up formnot signed inThe create actionsave was falseA users rowone row, unverifiedMail with a linka link, no rowThe link openedverified_at writtenSign-in formrow 1, cookie setPOST /session

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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

  1. Question 1 of 2 Why does a registrations controller need allow_unauthenticated_access?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Because the Authentication concern requires a session on every action, and a visitor signing up has none

    Every action in a generated app requires a session, so the one action a stranger must reach says so explicitly.

  2. Question 2 of 2 A sign-up controller declares rate_limit to: 5, within: 10.minutes. What does the sixth post from the same address inside ten minutes get?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A 429, and no user row or mail

    The limit is a before_action with its count in a cache store, so an attempt over the number never reaches create.

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.

One token decoded, then invalidated by a change, by time and by a byte Ruby · email_change_tokens.rb
# 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:

Timeline · 6 events in order One verification link, and the four ways it stops working Walk through one token. Each moment is a line the recorded run printed, at the clock it was moved to.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

  1. Question 1 of 2 The run decoded one token from generates_token_for. Which of these were inside it, before the signature?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The value the block returned
    • The expiry, and the window in the purpose line
    • The record id

    Id, block value, expiry and purpose are signed together, which is why the token needs no row and cannot be forged.

  2. Question 2 of 2 A link is opened three days after it was minted. What do find_by_token_for and find_by_token_for! do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The first returns nil; the second raises ActiveSupport::MessageVerifier::InvalidSignature

    Pick the plain version for a branch in the controller, the bang version to rescue one exception class.

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:

A password change with the challenge, and an address change held in unconfirmed_email Ruby · password_and_email_settings.rb
# 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:

Interactive · stage by stage Changing the address you sign in with, without locking yourself out Step through one change. The branch shows what happens to the first link when a second address is claimed.
Changing the address you sign in with, without locking yourself out, step 6 of 6. Showing Settings form (two fields), unconfirmed_email (second claim), Mail to the new (one signed link), Columns swapped (login: the new one), Old address told (the old address). In focus: Old address told.2nd claim: link deadSettings formtwo fieldsunconfirmed_emailsecond claimMail to the newone signed linkColumns swappedlogin: the new oneOld address toldthe old addressmail the old address

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 During a pending address change, which column does the person sign in with?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: email_address, until the link from the new address is opened

    The claim sits in its own column, and only confirming moves it into the column that authenticates.

  2. Question 2 of 2 A link was sent for asha@kaksha.example. The learner then claims asha.v@kaksha.example. What does the first link resolve to?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: nil, because the token's block returns unconfirmed_email, which has changed

    One pending claim at a time costs nothing once the link is signed with the claim rather than the current address.

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

  1. Question 1 of 2 A settings form posts a new password and no password_challenge key at all. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The password changes, because the validation runs only when the attribute is not nil

    nil skips the challenge and an empty string fails it, so the parameters must turn a missing field into "".

  2. Question 2 of 2 After an accepted password change, what should happen to the user's other session rows?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: They are destroyed, so only the browser that made the change stays signed in

    Keep the current session row and destroy the rest, and tell the user on the page that you did.

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.

An export and a deletion, each enqueued by the request and done by the job Ruby · export_and_delete.rb
# 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

  1. Question 1 of 2 What has changed when the export request returns, before the queue runs?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A job is waiting and nothing is ready to download

    The controller marks and enqueues; the work happens outside the request, where a slow walk of several tables belongs.

  2. Question 2 of 2 The deletion job ran and the receipts table still held two rows. What does that tell you about the design?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Receipts are kept for the accounts team, with the person removed from them

    A deletion decides row by row what goes, what stays for another purpose, and what is cleared from what stays.

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.

  1. 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 to nil when it is opened a second time.
  2. In the same copy, refuse a sign-in until the address is proven. Write a method that returns the user only when verified_at is present, and print what it answers for Asha before and after the link is opened.
  3. 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.
  4. In the same copy, record the change. Add a credential_events table and write one row for each accepted password change and each confirmed address change, with what changed and when, then print the two rows.
  5. 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 a guardian_name and 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.

  1. Question 1 of 12 Which of the three posts of sign-up does the Rails 8 authentication generator already write for you?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: The sign-in post

    The generator covers sign-in, sign-out and password reset, which leaves the registration and the verification action.

  2. Question 2 of 12 The first sign-up stored " Asha.Verma@Example.com " as asha.verma@example.com. A later form posts ASHA.VERMA@example.com. What does the run print?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Email address has already been taken, and the table still holds one row

    Normalising before validating is what makes the uniqueness rule mean one address rather than one spelling.

  3. Question 3 of 12 Sign-ups arrive from hundreds of addresses, a handful from each. Which change to rate_limit to: 5, within: 10.minutes helps most?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Add a second limit with its own name, keyed by something the sender cannot vary cheaply

    The key decides what a limit protects, and two limits on one controller need name: so their counters stay apart.

  4. Question 4 of 12 You change expires_in from 2.days to 6.hours on a token purpose. What happens to links already in people's inboxes?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: They all stop resolving, because the window is part of the signed purpose

    Treat a change of expires_in as an invalidation of every outstanding link for that purpose, and say so in the release note.

  5. Question 5 of 12 Why should a generates_token_for block never return something confidential?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: The block's value travels inside the token as readable JSON, so anyone holding the link can read it

    Signed is not encrypted: the payload is base64, not a secret, which is why the reset token signs only part of the salt.

  6. Question 6 of 12 An attacker with a borrowed signed-in browser changes the address. Which single measure gives the real owner the best chance of noticing?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: A note to the previous address after the swap

    The old address is the only place a change can be reported to someone whose browser has been taken over.

  7. Question 7 of 12 Which order of writes does a safe address change use?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Write the claim, mail the new address, then swap the columns when the link comes back

    Claim, proof, swap, notice: each step depends on the one before, and only the proof may move the login.

  8. Question 8 of 12 password_challenge is validated against the digest that was stored, not the one being written. Why does that matter in one update that changes the password?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: The new password has already replaced the digest in memory, so the check needs the value before the change

    Assigning password= rewrites the digest at once, so the challenge is compared with the previous value from dirty tracking.

  9. Question 9 of 12 Which other settings action deserves the same password challenge?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: The address change, because it moves where the sign-in and reset mail go

    Ask for the current password where a credential moves: the password itself, and the address that receives reset mail.

  10. Question 10 of 12 The deletion job is delivered twice. The second run found no user and did nothing. What property is that, and why does the queue need it?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: It is idempotent, and queues promise at-least-once delivery

    Write every job so a second delivery is a no-op: find the row, return if it has gone, and do nothing twice.

  11. Question 11 of 12 Which of these belong in an account's own data export?

    Choose every answer that is right.

    Show the answer: Question 11 of 12

    Answer:

    • The sessions and enrolments that belong to the account
    • Receipt numbers and amounts for payments they made
    • The account's email address and when it joined

    Export what the account owns in a form a person can read, and nothing that belongs to another account.

  12. Question 12 of 12 A coaching centre must keep fee receipts for its accounts. How does a deletion handle them?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Keep the rows, clear the link to the user and replace the names on them

    Deletion is per table: remove the person from rows that must stay, and destroy the rows that exist only for them.

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.

  1. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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_access because 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_for carries 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_email first 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_challenge validates 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.