Keyfob
QR device-handoff login for Phoenix LiveView — the "scan to sign in" pattern you know from WhatsApp Web and Telegram.
A browser shows a QR on the login page. You point your phone's camera at it, approve on the phone (where you're already signed in), and the browser logs in. No password typed on the new device.
Desktop (login page) Phone (already signed in)
──────────────────── ─────────────────────────
"Log in with QR code"
→ QR appears, waits ── scan ──► confirm screen:
"Sign in Chrome · macOS · 1.2.3.4?"
[Deny] [Approve]
signed in ✔ ◄─── push ─────────────── approves
Keyfob owns the rendezvous — the expiring, single-use meeting point between the two devices. It is auth-system-agnostic: it never writes a session or touches your users table. When a request is approved and consumed, your app issues the session with its own machinery. That boundary is the whole design — the same way a viewer library shouldn't own persistence, an auth-handoff library shouldn't own your session.
Why not just… do it yourself?
The easy 80% (mint a token, render a QR, PubSub a message) is a snippet. The 20% that's easy to get wrong is what Keyfob writes once and tests hard:
- The secret split. The token inside the QR is on a screen — semi-public. It must never become the credential. Keyfob mints a separate one-time login token at approval and delivers it only to the waiting LiveView.
- No auto-approve. The confirm screen is mandatory — it's the defense against QR-jacking (see Security).
- Single-use under races, short expiries, token rotation without dropping the waiting socket, approve-after-expire / double-scan / replay handling.
Install
def deps do
[{:keyfob, "~> 0.1"}]
end
Configure PubSub (you already have one) and add the store to your tree:
config :keyfob, pubsub: MyApp.PubSub
# application.ex
children = [
Keyfob.Store.ETS,
# ...
]
Integrate (3 touch points)
1. Login page (desktop)
def mount(_params, _session, socket) do
socket =
if connected?(socket) do
Keyfob.Live.init_panel(socket,
confirm_url: &url(~p"/login/qr/#{&1}"),
meta: device_meta(socket), # %{browser:, os:, ip:}
on_approved: &complete/2
)
else
assign(socket, :keyfob, nil)
end
{:ok, socket}
end
def handle_info({:keyfob, _t, _} = msg, socket),
do: Keyfob.Live.handle_message(msg, socket, on_approved: &complete/2)
def handle_event("keyfob_refresh", _p, socket),
do: {:noreply, Keyfob.Live.refresh(socket)}
# The login token can't be turned into a cookie from the LiveView, so hand
# it to a plain controller action that CAN set the session.
defp complete(socket, login_token),
do: {:noreply, push_navigate(socket, to: ~p"/login/qr/complete/#{login_token}")}
<Keyfob.Components.panel :if={@keyfob} qr_svg={@keyfob.qr_svg} state={@keyfob.state} />
2. Confirm page (phone, authenticated route)
def mount(%{"token" => token}, _session, socket) do
case Keyfob.peek(token) do
{:ok, %{state: :pending, meta: meta}} ->
{:ok, assign(socket, token: token, meta: meta, state: :pending)}
_ ->
{:ok, assign(socket, state: :expired)}
end
end
def handle_event("keyfob_approve", _p, socket) do
# current_user comes from YOUR auth — Keyfob just needs a stable ref.
:ok = Keyfob.approve(socket.assigns.token, socket.assigns.current_user.id)
{:noreply, assign(socket, :state, :approved)}
end
def handle_event("keyfob_deny", _p, socket) do
Keyfob.deny(socket.assigns.token)
{:noreply, assign(socket, :state, :denied)}
end
<Keyfob.Components.confirm_screen meta={@meta} state={@state} />
3. Completion action (controller — issues the session)
def complete(conn, %{"token" => login_token}) do
case Keyfob.consume(login_token) do
{:ok, user_id} ->
user = Accounts.get_user!(user_id)
MyAppWeb.UserAuth.log_in_user(conn, user) # YOUR session logic
{:error, _} ->
conn |> put_flash(:error, "Sign-in link expired.") |> redirect(to: ~p"/login")
end
end
That's it — identify the approver, then mint your own session. Keyfob does
the middle.
Phone-side scanning
The QR encodes a plain URL, so any phone's native camera opens it in the browser where the user is signed in — zero scanner code, works day one. An in-dashboard "link a device" scanner (a JS camera lib) is an optional nicety you add later; Keyfob doesn't need it.
Security
Read this part.
- QR-jacking is the threat. An attacker opens the login page, gets their QR, and socially engineers a victim into scanning + approving it — now the attacker is signed in as the victim. The only defense is the human confirm screen, which Keyfob makes mandatory and cannot be turned off. Show the device/browser/IP and word it as "you are signing someone else in." Do not build an auto-approve path.
- Secret split. The request token (in the QR) can identify a request but
never logs anyone in.
consume/2accepts only the login token minted at approval, delivered solely over PubSub to the waiting process. - Expiry + single use. Requests default to a 2-minute TTL, login tokens to
60 seconds; both are single-use, stored SHA-256-hashed, and rotate — refresh
the QR every ~60s (
Keyfob.Live.refresh/1) so a shoulder-surfed code is short-lived. - Rate-limit
create_request/1per IP where you already throttle logins, and audit via the telemetry events below ([:keyfob, :request, :approved]etc.). - Consider requiring re-auth (or step-up) on the phone before it can approve, and only offering QR login to accounts that opt in.
Keyfob is a mechanism, not a policy engine — these are your calls, but the defaults are chosen to fail safe.
Clustering
PubSub already crosses nodes. Only request storage is node-local in the
default Keyfob.Store.ETS. For multi-node, implement the Keyfob.Store
behaviour over shared storage (your DB, Redis) and set
config :keyfob, store: MyApp.KeyfobStore.
Telemetry
[:keyfob, :request, :created | :approved | :denied] and
[:keyfob, :login, :consumed], each with %{meta: ...} (and %{user_ref: ...}
on approve/consume).
License
MIT.