Passwordless Login With No Tokens Table

Passwordless Login With No Tokens Table

A capstone: Fizzy ships a complete, enumeration-resistant, replay-safe magic-link login with a credentials table that has no `used` boolean, no `consumed_at`, and no pending-logins table at all. Three principles you've already met collaborate — derive-don't-store (consume means destroy, expiry is a clock comparison), security-as-the-shape-of-data-access (an unknown email constructs the same object as a known one, so the two responses are byte-identical), and CRUD-on-a-noun (the whole flow is the REST lifecycle of a magic link and a session). Grounded entirely in real Fizzy code; Campfire has no passwordless auth, so Fizzy is the sole worked example.

Passwordless magic-link authentication modeled as CRUD on a noun Consume-means-destroy: deriving 'spent' from a row's absence instead of a `used` flag Expiry as a read-time range comparison (beginless/endless ranges), not a status to sweep Pending-login state as a signed, httponly, self-expiring cookie instead of a server-side table Binding credential to browser via a constant-time email compare at redeem Anti-enumeration by structural identity: fabricating an unsaved MagicLink so unknown emails are byte-identical to known ones Thin controllers / rich model: every hard part lives in MagicLink and Session fizzy by nityeshagarwal

You're building login, and you've decided to skip passwords. The user types their email, you mail them a six-digit code, they type it back, they're in. Simple — until you start writing it, and the schema starts breeding.

You'll need a row to hold the code so you can check it later. That row needs an expiry, so a stale code can't be redeemed. It needs a used boolean, so the same code can't be redeemed twice. Probably a consumed_at timestamp, for the audit trail. And once you've sent the code but before they type it, the browser is in a half-logged-in limbo — so you'll want a pending_logins table to remember "this browser is mid-sign-in for this email." Four columns and a second table, and you haven't checked a single code yet.

A clean technical teaching poster, square 1:1 canvas, off-white (#FAF7F2) groun…
This is the version you'd vibe-code first, and it's not wrong, exactly — it's just heavier than it needs to be. Fizzy ships the whole feature with a table that has no used column, no consumed_at, no status, and no second table for pending logins. The credential is consumed by being deleted. The "this browser is mid-login" state lives in a signed cookie that expires itself. And the entire flow — request a code, redeem a code — is just create-and-consume on one noun. This tutorial is a capstone: three principles you've already met collaborate on one real, security-rich feature. Campfire has no passwordless auth, so for this one Fizzy is the sole worked example.

Let's look at the naive schema honestly first, then watch each column dissolve.

The naive version: a credentials table that tracks its own lifecycle

Here's the migration and model you'd reach for, and it's the honest first draft, not a strawman:

# the schema you'd write first
create_table :login_tokens do |t|
  t.references :user
  t.string   :code
  t.datetime :expires_at
  t.boolean  :used, default: false   # so a code can't be redeemed twice
  t.datetime :consumed_at            # for the audit trail
  t.timestamps
end

create_table :pending_logins do |t|   # "this browser asked for a code, waiting for it"
  t.references :user
  t.string :email
  t.datetime :created_at
end

class LoginToken < ApplicationRecord
  belongs_to :user

  def redeem!
    return false if used?                 # guard: already redeemed
    return false if expires_at.past?       # guard: expired
    update!(used: true, consumed_at: Time.current)
    true
  end
end

Count the state you've signed up to maintain. used is a flag you must flip at exactly the right instant — and if a redeem succeeds but the session-creation that follows raises, you've burned a valid code and the user is stuck. expires_at needs a sweeper job or those rows pile up forever. The pending_logins table is a whole second entity whose only job is to remember a transient fact ("this browser is waiting for its code") that you then have to clean up, and that — if you're not careful — lets a code mailed to one email be redeemed by a browser that claimed a different one. The model is a little lifecycle machine: a row that is born, waits, gets flagged, and lingers as a tombstone.

Three independent weights, and every one of them is optional. Watch them come off.

Step 1 — Consume means destroy (derive, don't store)

Start with the used boolean, because it's the most tempting and the most unnecessary. The question it answers is "has this code already been redeemed?" The naive model stores the answer as a flag. But there's a cheaper representation of "this code is spent": the row isn't there anymore.

Here is Fizzy's entire MagicLink model — the whole 43-line file, read top to bottom (magic_link.rb:1-43):

class MagicLink < ApplicationRecord
  CODE_LENGTH = 6
  EXPIRATION_TIME = 15.minutes

  belongs_to :identity

  enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in

  scope :active, -> { where(expires_at: Time.current...) }
  scope :stale, -> { where(expires_at: ..Time.current) }

  before_validation :generate_code, on: :create
  before_validation :set_expiration, on: :create

  validates :code, uniqueness: true, presence: true

  class << self
    def consume(code)
      active.find_by(code: Code.sanitize(code))&.consume
    end

    def cleanup
      stale.delete_all
    end
  end

  def consume
    destroy
    self
  end

  private
    def generate_code
      self.code ||= loop do
        candidate = Code.generate(CODE_LENGTH)
        break candidate unless self.class.exists?(code: candidate)
      end
    end

    def set_expiration
      self.expires_at ||= EXPIRATION_TIME.from_now
    end
end

The hinge is the four lines at the bottom (magic_link.rb:27-30):

  def consume
    destroy
    self
  end

Redeeming a magic link destroys it. There is no used = true; there is no row left to mark. The return value is self — the in-memory object survives so the caller can read magic_link.identity off it — but the database row is gone the instant it's consumed. This is derive, don't store in its sharpest form: the fact "this code is spent" is derived from the row's absence, not stored in a column. We don't re-derive that worldview here — it has its deep home in [[see P2: Derive, Don't Store]] — but watch how many of the naive weights this one decision lifts.

The double-redeem bug is gone by construction. The class method that wraps it (magic_link.rb:18-20) only ever looks at live, unexpired rows:

    def consume(code)
      active.find_by(code: Code.sanitize(code))&.consume
    end

The first redeem finds the row through the active scope and destroys it.

A clean technical sequence diagram poster, square 1:1, off-white (#FAF7F2) grou…
The expiry follows the same instinct. Look at the two scopes (magic_link.rb:9-10):

  scope :active, -> { where(expires_at: Time.current...) }
  scope :stale, -> { where(expires_at: ..Time.current) }

Those are beginless and endless ranges — Time.current... means "from now onward," ..Time.current means "up to now." Expiry isn't a status you flip from active to expired on a schedule; it's a question about time, answered by comparing expires_at to the clock at read time. A code is expired the moment the wall clock passes its expires_at, with nothing written. The cleanup method (magic_link.rb:22-24) does eventually delete_all the stale rows, but that's pure housekeeping to reclaim disk — correctness never depended on it running, because consume only ever queries active. An expired code that's never swept is still un-redeemable. (This is the same shape as Campfire's presence TTL — flags lie, so don't keep one; ask the clock instead. That instinct lives in [[see P2: Derive, Don't Store]].)

"If the row is destroyed, how does the caller know who just logged in? Isn't the identity gone with it?" No — consume returns self, the still-in-memory Ruby object, after destroying its database row. Active Record doesn't blank out the attributes on destroy; the object keeps its loaded identity association. So MagicLink.consume(code).identity works perfectly: the row is gone from the table, but the object in hand still answers .identity. The destroy removes the credential; it doesn't remove the answer to "who" that the controller needs for the next half-second.

The contrast: the naive LoginToken carries used and consumed_at, flips them inside redeem!, and grows a tombstone row for every login ever performed — plus a sweeper you must remember to schedule, and a redeem-then-save race that can burn a valid code. Fizzy's MagicLink carries neither flag. Consuming is destroy; the double-redeem guard is the row's absence; expiry is a range comparison at read time; cleanup is optional housekeeping, not correctness. Two columns and a class of races, deleted by deriving "spent" from "gone."

Step 2 — The pending-login state is a self-expiring cookie, not a table

Now the second table. Between "user submitted their email" and "user typed the code back," something has to remember which email this browser is signing in as. If you don't bind that, here's the hole: I request a code for victim@example.com, the victim's code gets mailed to the victim — but my browser is the one waiting at the "enter code" screen. If the system just trusts whatever code I type, and I somehow obtain any valid code, I could be matched to the wrong identity. You need to pin "this browser is mid-login as this specific email" somewhere.

The naive instinct is the pending_logins table — a server-side row per in-flight login. Fizzy puts that fact where it actually belongs: in a signed, httponly, self-expiring cookie on the browser that's doing the logging in (via_magic_link.rb:43-50):

    def set_pending_authentication_token(magic_link)
      cookies[:pending_authentication_token] = {
        value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at),
        httponly: true,
        same_site: :lax,
        expires: magic_link.expires_at
      }
    end

Read what's inside the cookie. It's a signed messagemessage_verifier(:pending_authentication).generate(email_address, expires_at:) — carrying the one fact that matters: the email this browser is mid-login as. Signed, so the browser can't tamper with it to claim a different email (a forged value fails verification). httponly, so page JavaScript can't read it. And it carries the same expires_at as the magic link itself, set both as the verifier's own expiry and as the cookie's expires: — so the "pending login" state dies on exactly the same clock as the code. No row to create, no row to sweep, no pending_logins table at all. The browser holds its own pending state, cryptographically sealed and time-bombed to self-destruct.

A clean technical diagram poster, square 1:1, off-white (#FAF7F2) ground, gener…
When the code comes back, the controller reads that email straight off the verified cookie (via_magic_link.rb:52-54):

    def email_address_pending_authentication
      pending_authentication_token_verifier.verified(pending_authentication_token)
    end

verified returns the email if the signature checks out and the token hasn't expired, and nil otherwise. So the magic-links controller can guard its entire screen on one line — there's no point showing the "enter code" form to a browser that has no pending login (magic_links_controller.rb:21-22):

    def ensure_that_email_address_pending_authentication_exists
      unless email_address_pending_authentication.present?

Then the binding is enforced at redeem time. The code you typed is looked up globally (any valid code resolves to its identity), but it only signs you in if that code's email matches this browser's pending email (magic_links_controller.rb:35-41):

    def authenticate(magic_link)
      if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address)
        sign_in magic_link
      else
        email_address_mismatch
      end
    end

secure_compare (constant-time, so it leaks nothing through timing) checks that the email sealed in this browser's cookie equals the email on the consumed magic link. A code mailed to the victim, typed into the attacker's browser, fails this compare — because the attacker's cookie says they're mid-login as themselves, not the victim. The binding email→browser lives entirely in the signed cookie; the database never grew a row to hold it.

"A cookie holding login state — isn't that exactly the thing you're not supposed to trust from the client?" You're not trusting the client; you're trusting your own signature. The cookie is generated by Rails.application.message_verifier(:pending_authentication) and read back through .verified — if a single byte is altered, verification fails and you get nil, same as no cookie. The client is holding an opaque, tamper-evident token it can present but cannot forge or read. That's the difference between "client-side state you trust" (a bug) and "a signed claim the server minted and will re-verify" (a session cookie, which is exactly what every Rails app already does). The pending-login table would store the same fact server-side at the cost of a row's lifecycle; the signed cookie stores it client-side at the cost of nothing.

The contrast: the naive design grows a pending_logins table — a row born when you request a code, needing its own cleanup, its own "does this browser own this pending login" check. Fizzy carries the one fact that matters (which email this browser is mid-login as) in a signed, httponly cookie that expires on the same clock as the code, and enforces the email→browser binding with one constant-time compare at redeem. The second table, and the cross-browser confusion it was meant to prevent, both disappear.

Step 3 — An unknown email is byte-identical to a known one (anti-enumeration)

Here's the security hole the naive version never even thinks about. A login form that behaves differently for known and unknown emails is a free user directory for anyone who wants one. Type ceo@target-company.com: if the response is "code sent," that email has an account; if it's "no such user," it doesn't. An attacker scripts a thousand guesses and walks away with a verified list of your customers. This is email enumeration, and the only fix is to make the two cases indistinguishable — same screen, same timing, same everything, whether the email exists or not.

Look at how the sessions controller branches (sessions_controller.rb:14-22):

  def create
    if identity = Identity.find_by(email_address: email_address)
      sign_in identity
    elsif Account.accepting_signups?
      sign_up
    else
      redirect_to_fake_session_magic_link email_address
    end
  end

Three branches, and here's the thing: the user can't tell which one they hit. A known identity gets a real magic link mailed and is redirected to the "enter your code" screen. An unknown email, when signups are closed, hits that last branch — and instead of an error, it gets redirect_to_fake_session_magic_link, which lands them on the exact same "enter your code" screen. The unknown-email path is engineered to be byte-identical to the known one.

The mechanism is the elegant part. To make the unknown path indistinguishable, you need a MagicLink to drive the redirect — but you must not create a real one (that would mail a stranger a code and leave a row in the table). So Fizzy fabricates an unsaved MagicLink (via_magic_link.rb:15-23):

    def redirect_to_fake_session_magic_link(email_address, **options)
      fake_magic_link = MagicLink.new(
        identity: Identity.new(email_address: email_address),
        code: SecureRandom.base32(6),
        expires_at: MagicLink::EXPIRATION_TIME.from_now
      )

      redirect_to_session_magic_link fake_magic_link, **options
    end

MagicLink.new, not create. It has a freshly random code, a real-looking expires_at, and an Identity.new (also unsaved) wrapping the typed email — everything the downstream redirect code reads, with nothing written to the database. It then flows into the same redirect_to_session_magic_link (via_magic_link.rb:25-34) that a real login uses — which sets the pending-authentication cookie and redirects to the magic-link screen exactly as a genuine request would. The browser of someone typing a stranger's email sees: "Check your email, enter your code." Same page, same cookie set, same words. No email is actually mailed (there's no saved link, so no mailer fires), so the code never arrives — but the attacker can't observe that. From the outside, a non-existent account is structurally identical to a real one waiting on its code.

A clean technical diagram poster, square 1:1, off-white (#FAF7F2) ground, gener…
This is security as the shape of your data access taken to its limit — the deep worldview is [[see P3: Security Is the Shape of Your Data Access]]'s, and we don't re-derive it here. The Fizzy-specific sharpening: anti-enumeration isn't a guard clause you bolt on (return render :ok if user_unknown), which is exactly the kind of branch a refactor eventually "cleans up" by adding a distinguishing message. It's structural. The unknown path constructs the same object the known path does and runs the same code, so the two responses are identical because they are the same response. There is no separate "user not found" rendering for an attacker to detect, because the codebase declines to ever build one.

"Why fabricate a whole fake object — why not just render the 'check your email' page directly for unknown emails?" Because "render the same page" is a promise you have to keep, by hand, forever — and it drifts. The day someone refactors the real path to also set a header, or tweak a flash, or branch on magic_link.for_sign_up?, the fake path that re-implements the response silently falls out of sync, and the timing or the markup diverges by one detectable byte. Building the same MagicLink object and flowing it through the same redirect_to_session_magic_link means the two paths can't diverge: there's one response-construction code path, exercised by both a real and a fake link. Indistinguishability is guaranteed by shared code, not by a developer remembering to keep two branches matching.

The contrast: the naive login renders "code sent" for known emails and "no account found" for unknown ones — a working enumeration oracle, handed out for free. Fixing that by hand means carefully matching two response branches and hoping no future edit breaks the parity. Fizzy builds an unsaved MagicLink for the unknown case and runs it through the identical redirect path, so the unknown-email response is the known-email response — same screen, same cookie, same timing — with zero rows written and no second branch to keep in sync.

Step 4 — The whole flow is CRUD on a MagicLink

Step back and notice what kind of thing "login" even is here. There's no LoginController with a def authenticate custom action, no /verify_code endpoint, no verbs bolted onto a controller. There are two resources, and login is the ordinary REST lifecycle of one of them.

Requesting a code is creating a session magic link. Redeeming a code is creating the magic-link resource's redemption. The routes say exactly this (routes.rb:160-167):

  resource :session do
    scope module: :sessions do
      resources :transfers
      resource :magic_link
      resource :menu
      resource :passkey, only: :create
    end
  end

resource :magic_link — a singular RESTful resource nested under the session. Its new/create/show are the entire passwordless flow: show renders the "enter your code" screen, create redeems the code. No custom action exists, because none is needed; this is verb-as-noun — "log in" found its noun, and the noun is the magic link. (We don't re-derive the principle; it's [[see P6: Model Every State Change as CRUD on a Noun]]'s. And it isn't a Fizzy accident — STYLE.md:138-153 writes it down as house law: "When an action doesn't map cleanly to a standard CRUD verb, we introduce a new resource rather than adding custom actions." Login obeyed the rule.)

The redeem action is the cleanest possible expression of it (magic_links_controller.rb:12-18):

  def create
    if magic_link = MagicLink.consume(code)
      authenticate magic_link
    else
      invalid_code
    end
  end

Five lines. MagicLink.consume(code) either returns the consumed link (valid, unexpired, now destroyed) or nil (wrong, expired, or already used — all the same outcome: not found). On success, authenticate runs the email→browser binding from Step 2 and starts the session. The controller is thin to the point of transparency because every hard part lives in the model: the consume-means-destroy is MagicLink#consume, the expiry is the active scope, the binding is the cookie. The controller just orchestrates the two nouns — consume the link, mint the session.

And minting the session is itself CRUD on the other noun (authentication.rb:96-100):

    def start_new_session_for(identity)
      identity.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
        set_current_session session
      end
    end

identity.sessions.create! — logging in is creating a Session. Logging out, elsewhere, is destroying it. Two resources, four standard verbs, and the entire authentication system is CRUD: create a magic link (request a code), create-by-consuming it (redeem), create a session (log in), destroy a session (log out). There is no auth machinery — there are nouns, and you do ordinary things to them.

The contrast: the naive version reaches for a SessionsController#authenticate custom action, maybe a LoginToken#redeem! verb, a /login, a /verify, a /logout — a pile of bespoke endpoints each describing an action. Fizzy models the same flow as the REST lifecycle of two nouns (magic link, session), so the routing table reads as a vocabulary of resources, the controllers stay five lines, and STYLE.md can state the rule as law because the code already lives it.

The whole journey, on one screen

Trace one login end to end, and watch how little gets written down anywhere.

A user types their email and submits. SessionsController#create finds their Identity, calls send_magic_link — which create!s a real MagicLink (random code, 15-minute expiry, before_validation callbacks filling both in) and mails it. redirect_to_session_magic_link seals the user's email into a signed, httponly cookie that expires on the same clock as the link, and redirects to the "enter your code" screen. If the email had been unknown and signups closed, an unsaved fake MagicLink would have flowed through the identical redirect — same screen, same cookie, no row, no email — so an attacker can't tell the two apart.

The code arrives by email; the user types it back. Sessions::MagicLinksController#create calls MagicLink.consume(code): the active scope finds the live, unexpired row by its sanitized code, and consume destroys it and returns the in-memory object. authenticate constant-time-compares the email sealed in this browser's cookie against the consumed link's identity email — they match, so this is the right browser. start_new_session_for creates a Session row and sets the session cookie. The user is in.

Now count the rows that persist after a successful login: one — the Session. The MagicLink was destroyed on consume. The pending-authentication state was a cookie that's now cleared. There was never a pending_logins row, never a used flag, never a consumed_at. The credential left no tombstone; the in-flight state left no table; the unknown-email defense left no branch. What's on disk is the one durable noun — the session you're now holding — and nothing else.

A large, dense 'whole journey on one screen' technical master diagram, square 1…
That's the capstone aha, stated plainly: a complete, enumeration-resistant, replay-safe passwordless login is the collaboration of three conventions that already existed for other reasons. Derive, don't store turns "spent" into "gone" and "expired" into a clock comparison, deleting two columns and a sweeper's correctness role. Security as the shape of your data access makes the unknown-email path construct the same object the known path does, so indistinguishability is guaranteed by shared code, not by hand-matched branches. And CRUD on a noun makes the whole flow the REST lifecycle of a magic link and a session, so the controllers stay five lines and the routing table carries the meaning. Count the edge cases this design absorbs for free — the double-redeem, the burned-code race, the orphaned pending row, the cross-browser confusion, the enumeration oracle — and nearly every one is absorbed by a decision that wasn't about that edge case at all.

Key Takeaways — Patterns to Steal

  • When you build a redeemable credential, the reflex is a used boolean (or consumed_at) you flip on redemption — but that's a flag you must set at exactly the right instant, and a redeem-then-save that rolls back burns a valid code. Consume by destroying the row instead, so "already used" is the row's absence, not a column. Fizzy's MagicLink#consume is destroy; self (magic_link.rb:27-30), and MagicLink.consume(code) looks the code up through the active scope (magic_link.rb:18-20) — a second redeem simply finds nothing, so the double-redeem guard is structural and there's no flag to keep honest.

  • Don't model expiry as a status you flip from active to expired on a schedule; a code is expired the moment the wall clock passes its expires_at, with nothing written. Make expiry a read-time range comparison: Fizzy's scopes are active -> where(expires_at: Time.current...) and stale -> where(expires_at: ..Time.current) (magic_link.rb:9-10), so consume only ever queries live rows and an un-swept expired code is still un-redeemable. The cleanup/delete_all (magic_link.rb:22-24) is disk housekeeping, never correctness — flags lie, so ask the clock instead.

  • The "this browser asked for a code and is waiting" state tempts you into a pending_logins table — a row with its own lifecycle and cleanup. Put that one transient fact (which email this browser is mid-login as) in a signed, httponly, self-expiring cookie instead. Fizzy seals the email with message_verifier(:pending_authentication).generate(email, expires_at:) into a cookie whose expires: matches the link's (via_magic_link.rb:43-50); it's tamper-evident (a forged byte fails .verified) and dies on the same clock as the code — no second table, no sweeper.

  • Bind the credential to the browser that requested it, or a code mailed to one email could be redeemed by a browser claiming another. Enforce the binding with one constant-time compare at redeem: Fizzy checks secure_compare(email_address_pending_authentication, magic_link.identity.email_address) (magic_links_controller.rb:35-41) — the email sealed in this browser's cookie against the email on the consumed link. A code typed into the wrong browser fails the compare; the binding lives in the cookie, not in a row.

  • A login form that answers differently for known vs unknown emails is a free user directory. Don't fix it with a guard clause (render :ok if unknown) — that re-implements the response and drifts the day someone edits the real path. Make the unknown path construct the same object and run the same code: Fizzy builds an unsaved MagicLink.new(identity: Identity.new(email_address:), code: SecureRandom.base32(6), …) (via_magic_link.rb:15-23) and flows it through the identical redirect_to_session_magic_link, so the unknown-email response is byte-identical to the known one — same screen, same cookie, zero rows written, no email sent — and indistinguishability is guaranteed by shared code, not hand-matched branches.

  • Don't invent a LoginController with authenticate/verify/logout custom actions; "log in" has a noun. Model the flow as the REST lifecycle of two resources — resource :magic_link under resource :session (routes.rb:160-167) — where redeeming is create (magic_links_controller.rb:12-18) and logging in is identity.sessions.create! (authentication.rb:96-100). STYLE.md:138-153 states the rule as law ("introduce a new resource rather than adding custom actions"), and login obeys it: five-line controllers, the routing table carrying the meaning, every hard part in the model.

  • After a successful login, count what persists: one Session row. The MagicLink was destroyed on consume, the pending-auth state was a cookie now cleared, and there was never a pending_logins table or a used/consumed_at column. Aim for that — let the single durable noun be the only thing on disk, and let the credential, the in-flight state, and the unknown-email defense each leave no tombstone. The double-redeem, the burned-code race, the orphaned pending row, the cross-browser mix-up, and the enumeration oracle are all absorbed by decisions that weren't about those edge cases at all.