Scan a QR on your authentik login page with your phone, approve it with a passkey and a fingerprint, and the laptop signs itself in. The service holds NO authentik credential. A flow policy calls it with a session id and gets back the username that approved it, or nothing -- so there is no standing credential to steal. The obvious alternative, authentik's recovery-link API, effectively requires a superuser and was rejected for that reason. See README.md for the traps this had to work around, including the two flow-binding settings that are counter-intuitive and load-bearing, and an honest account of what QR sign-in cannot defend against.
8.3 KiB
authentik-qr-login
Scan a QR code on your laptop's login page with your phone, approve it with a passkey and a fingerprint, and the laptop signs itself in. No typing on the laptop, no password anywhere.
authentik has no such feature — there is no QR login and no push/number-match stage — and this adds one without forking authentik and without holding any authentik credential.
LAPTOP THIS SERVICE PHONE
sso.example.com/if/flow/… sso.example.com/qr-login/
┌──────────────────────┐
│ authentik login page │ POST /api/session
│ ┌────────────────┐ │──────────────► id + approval token + number
│ │ ▄▄▄▄▄ ▄ ▄▄ │ │
│ │ █ ▄ █ ▀█▄ ▀█ │ │ GET /a/:token ◄──── scan
│ │ █▄▄▄█ █ ▄▀▄█ │ │ │
│ │ 27 │ │ passkey + fingerprint
│ └────────────────┘ │ │
│ [ Continue ] │ "Approve sign-in?
│ │ from 192.168.8.51
│ │ Firefox on Fedora"
│ │ → type 27
│ ◄────────── approved ──────────────────────┘
│ signs itself in │
└──────────────────────┘
Why it is safe
authentik asks the service; the service never tells authentik anything.
An authentik flow policy calls POST /api/assert with a session id and gets
back the username that approved it, or nothing. The service holds no API token,
so there is no standing credential to steal, and the most it can ever say is
"whoever holds this id approved as <user>".
The tempting alternative — having the service call authentik's recovery-link API
to mint a one-time login URL — was tried and rejected. That endpoint effectively
requires a superuser: reset_user_password (global and object-level),
view_user, change_user, impersonate, add_flowtoken and view_flow all
return 403, while is_superuser succeeds immediately. A leak of that token mints
a session for any account in the directory.
The passkey is the only authentication in the whole chain, so the phone-side
OIDC client pins its authentication_flow to a passwordless/passkey flow and
every authorize request carries prompt=login&max_age=0. Without both, an
existing session or a password login would satisfy the client and the guarantee
disappears silently.
Honest limitations
QR sign-in is phishable and this cannot fix it. An attacker can render this service's QR on their own page. The victim scans it and authenticates for real against the real identity provider — so passkey origin binding does not help — sees a number the attacker's page displays, and approves. The attacker's browser gets the session.
WebAuthn's own hybrid transport avoids this with a Bluetooth proximity check. There is no equivalent here. What is offered instead:
| Mitigation | What it actually buys |
|---|---|
| Keep the IdP off the public internet | By far the strongest control. The attacker must already be inside. |
| Laptop IP + reverse DNS shown above the number entry | The only thing standing between a phished QR and a stolen session is the user noticing the device is not theirs. |
| Number matching | Defeats blind approval-bombing. Nothing else — it does not authenticate the requester. |
| Same-subnet check | A warning by default; a hard block on request. |
| 90-second TTL, single use | The attacker must be live and synchronous. |
Number matching defaults to typing, not tapping. Three buttons is
log₂(3) = 1.6 bits: with one attempt and a session cap an attacker still wins
about 70% of the time, which is not a control. Two typed digits is 6.5 bits and
about 3.3%. Set QR_NUMBER_MODE=choice for the friendlier version, with eyes
open. A wrong answer denies outright — a retry loop would undo the arithmetic.
Sessions live in memory. They are 90-second capabilities to become a user;
one that outlives the process is a liability. Run a single replica with
strategy: Recreate. A restart drops in-flight sign-ins, which costs a retry.
Requirements, and they are not negotiable
1. It must be served on the same origin as authentik, e.g.
https://sso.example.com/qr-login. Not a subdomain — the same origin.
The panel is an iframe on authentik's login page, and when the phone approves it
navigates the top-level window. A cross-origin frame cannot do that without a
user gesture (Chrome's framebusting intervention; Chrome, Firefox and Safari all
differ on the specifics). A same-origin frame is explicitly exempt. The service
refuses to start if QR_OIDC_ISSUER is not on QR_PUBLIC_ORIGIN.
2. If your ingress merges Ingresses per host, the second one still needs its
own tls: block. On Cilium's shared-mode ingress controller, an Ingress
sharing a host but omitting tls has its route silently dropped from the secure
listener, and the path falls through to authentik with no error anywhere. Since
cross-namespace TLS secret references are disallowed, the simplest answer is to
deploy into authentik's own namespace and reuse its certificate.
authentik configuration
Four objects. A Pulumi example is in deploy/.
- An OIDC provider + application for the phone, with
authentication_flowpinned to your passkey flow, and redirect URIhttps://<host>/qr-login/oidc/callback. - A completion flow (
qr-login-complete, designationauthentication, authenticationnone) containing a user-login stage. - An expression policy bound to that stage's binding — see below.
- A prompt stage with one
staticfield whose value is an iframe pointing at/qr-login/panel, bound to your login flow. It has no inputs and no policies, so if this service is down the browser renders an empty box and authentik's own Continue button still works.
The two settings everyone gets wrong
On the login stage's flow-stage binding:
evaluate_on_plan = false # NOT true
re_evaluate_policies = true
- With
evaluate_on_plan: truethe policy runs twice, and since the assert consumes the session id, the second pass finds it spent and drops the stage. A flow whose plan ends up empty redirects to/— so the phone approves, the laptop navigates, and lands back on the login page. - You cannot tell the two passes apart:
request.context["flow_plan"]exists at plan time too, contrary to what you might assume. - And authentik caches flow plans (
cache.timeout_flows, 300s by default) under a key that is identical for every anonymous user — so a plan-time evaluation is served from cache and the policy never runs at all.ReevaluateMarkersetsengine.use_cache = False, which makes the stage-time run the only evaluation guaranteed to happen.
In the policy itself, assigning to request.context["pending_user"] is
silently discarded. It must be:
request.context["flow_plan"].context["pending_user"] = user
Configuration
| Variable | Default | |
|---|---|---|
QR_PUBLIC_ORIGIN |
— | e.g. https://sso.example.com |
QR_BASE_PATH |
/qr-login |
path prefix |
QR_OIDC_ISSUER |
— | must be on QR_PUBLIC_ORIGIN |
QR_OIDC_CLIENT_ID / QR_OIDC_CLIENT_SECRET |
— | the phone-side client |
QR_REQUIRED_GROUP |
— | group the ID token must list; empty is rejected, never "allow all" |
QR_ASSERT_SECRET |
— | proves an /api/assert caller is authentik's policy |
QR_COOKIE_SECRET |
— | signs the phone's short-lived cookie |
QR_SESSION_TTL_SECONDS |
90 |
|
QR_NUMBER_MODE |
type |
or choice |
QR_REQUIRE_SAME_SUBNET |
false |
hard-block instead of warn |
QR_MAX_SESSIONS |
10000 |
bound, so a flood is bounded memory |
Development
npm install
npm run typecheck
npm test # 9 tests, no network
npm run build
The Dockerfile runs the typecheck and the tests, so a service that does not compile or does not pass cannot produce an image.
Licence
MIT. See LICENSE.