Files
authentik-qr-login/README.md
Michal 44061918ac authentik-qr-login: cross-device QR sign-in for authentik
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.
2026-08-16 17:20:57 +01:00

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/.

  1. An OIDC provider + application for the phone, with authentication_flow pinned to your passkey flow, and redirect URI https://<host>/qr-login/oidc/callback.
  2. A completion flow (qr-login-complete, designation authentication, authentication none) containing a user-login stage.
  3. An expression policy bound to that stage's binding — see below.
  4. A prompt stage with one static field 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: true the 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. ReevaluateMarker sets engine.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.