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:

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.

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.