QR device-handoff login for Phoenix LiveView.
A browser that wants in shows a QR code; an already-authenticated phone scans it and approves; the waiting browser signs in. Keyfob owns the rendezvous — request lifecycle, expiry, single-use enforcement, the secret split, and the PubSub signaling. It never touches your session, users table, or cookies: your app issues the credential when Keyfob hands it an approved, consumed request.
The flow
- The login page calls
Keyfob.Live.init_panel/2— a pending request is minted and a QR encoding your confirm URL (with the request token) is rendered byKeyfob.Components.panel/1. The LiveView waits. - The phone (logged in) opens the confirm URL. Your confirm page shows
Keyfob.Components.confirm_screen/1and, on the user's explicit Approve, callsKeyfob.approve/3with your user's identifier. - Approval mints a separate one-time login token delivered over PubSub only to the waiting LiveView, which navigates to your completion endpoint.
- Your endpoint calls
Keyfob.consume/2—{:ok, user_ref}, exactly once — and logs that user in with your own machinery.
The secret split
The request token travels inside a QR on a screen, so it is treated as
semi-public: it can identify a request but never log anyone in. The
login token minted at approval is the credential; it exists only in the
PubSub message to the waiting process and dies on first consume/2.
Configuration
config :keyfob,
pubsub: MyApp.PubSub, # required (or pass pubsub: per call)
store: Keyfob.Store.ETS # defaultAdd the default store to your supervision tree:
children = [
Keyfob.Store.ETS,
...
]The ETS store is single-node. For clustered deployments implement
Keyfob.Store over a shared store (database, Redis) — PubSub already
crosses nodes, only the request storage needs to.
Security posture
- Tokens are 256-bit random, stored hashed (SHA-256).
- Requests expire (default 2 minutes) and are single-use.
- Approval requires an explicit
user_ref— there is no auto-approve, by design: the confirm screen is the defense against QR-jacking (an attacker showing a victim the attacker's login QR). - The login token expires fast (default 60 seconds) and
consume/2deletes it atomically.
Rate-limiting request creation (per IP) is left to the host — do it where you already rate-limit login attempts. Telemetry events are emitted for every transition (see below) so approvals are auditable.
Telemetry
[:keyfob, :request, :created][:keyfob, :request, :approved][:keyfob, :request, :denied][:keyfob, :login, :consumed]
Each with metadata %{meta: request_meta} (plus %{user_ref: ...} on
approve/consume).
Summary
Functions
Approves a pending request on behalf of user_ref (your user's stable
identifier — an id, a uuid; Keyfob treats it as opaque).
Exchanges a login token for the approved user_ref — exactly once.
Mints a pending login request.
Denies a pending request — broadcasts {:keyfob, token, :denied} to the
waiting LiveView and deletes the request. Returns :ok regardless of
whether the request still existed (deny is idempotent).
Looks a request up by its token without changing it — for rendering the
confirm screen. Returns {:ok, %{state: state, meta: meta, expires_at_ms: ms}} or {:error, :not_found | :expired}.
Subscribes the calling process to a request's topic.
The PubSub topic for a request token. Subscribe before rendering the QR.
Unsubscribes the calling process from a request's topic.
Types
Functions
@spec approve(token(), user_ref(), keyword()) :: :ok | {:error, :not_found | :expired | :not_pending | :invalid_user_ref}
Approves a pending request on behalf of user_ref (your user's stable
identifier — an id, a uuid; Keyfob treats it as opaque).
Mints the one-time login token, binds it to the request, and broadcasts
{:keyfob, token, {:approved, login_token}} on the request's topic —
received only by the waiting LiveView.
Options: :login_ttl_ms (default 60000), plus
:store / :pubsub overrides.
Returns :ok, or {:error, :not_found | :expired | :not_pending}.
user_ref must not be nil.
Exchanges a login token for the approved user_ref — exactly once.
Call this from the completion endpoint the waiting LiveView navigates
to. On success both the login token and the underlying request are
deleted, so a replayed URL gets {:error, :not_found}.
@spec create_request(keyword()) :: {:ok, %{token: token(), expires_in_ms: pos_integer()}}
Mints a pending login request.
Options:
:meta— map describing the requesting device (browser, os, ip); shown verbatim on the confirm screen. Default%{}.:ttl_ms— request lifetime. Default120000.:store/:pubsub— override configuration.
Returns {:ok, %{token: token, expires_in_ms: ttl}}. The token is the
only handle the caller keeps; everything at rest is hashed.
Denies a pending request — broadcasts {:keyfob, token, :denied} to the
waiting LiveView and deletes the request. Returns :ok regardless of
whether the request still existed (deny is idempotent).
Looks a request up by its token without changing it — for rendering the
confirm screen. Returns {:ok, %{state: state, meta: meta, expires_at_ms: ms}} or {:error, :not_found | :expired}.
Subscribes the calling process to a request's topic.
The PubSub topic for a request token. Subscribe before rendering the QR.
Unsubscribes the calling process from a request's topic.