Skip to main content

Login with bkey

“Sign in with bkey” is standard OIDC authorization code + PKCE. Your site redirects to BKey, the user approves on their phone with their face, and you get back a stable identifier for that user. No user password exists anywhere in the flow — nothing to leak, phish, or reset. Your application still holds long-lived credentials of its own, issued below. Those need the usual care. This is a different flow from CIBA. Use Login with bkey to sign a human into your website. Use CIBA to get a human to approve a specific action your backend is about to take.

Get credentials — self-serve, no account needed

Client registration is RFC 7591 dynamic registration. No dashboard, no account, no sales call: one request gives you a client_id and client_secret.

Save both credentials

A successful registration returns two secrets, each shown exactly once:
They do different jobs, and losing either is expensive:
  • client_secret authenticates your client at the OAuth endpoints.
  • registration_access_token manages the client — it is the only credential that can read, update, rotate, or delete an anonymously registered client. The client secret cannot do any of those.
Store the registration access token like a password, separately from the client secret, and keep registration_client_uri with it. Discard it and you lose the ability to fix a mistyped redirect URI or rotate a leaked secret.
post_logout_redirect_uris is persisted even though the registration response does not echo it back. Read the client with getRegisteredClient() to confirm what was stored.

Changing things later

Provided you kept the registration access token, the client is fully editable — see the lifecycle helpers in Integrate BKey:
  • updateRegisteredClient() changes redirect_uris, post_logout_redirect_uris, and the client name.
  • rotateClientSecret() issues a new secret, with a grace window for the old one. Use graceHours: 0 after a leak.
  • claimRegisteredClient() transfers an anonymous client to an account, so it is no longer managed by that one token.
Prefer rotation over re-registration. Registering a replacement leaves the original client live with a non-expiring secret, so a leaked credential stays valid forever and you accumulate an orphaned client. One thing worth getting right first time: redirect_uris must match your callback path exactly. For the Auth.js preset below that is /api/auth/callback/bkey. BKey accepts http:// redirect URIs only for loopback addresses (http://localhost:3000/…), per RFC 8252. Every other origin must be HTTPS — including a LAN address, so testing against a phone on your local network needs an HTTPS tunnel.

Use the issuer https://id.bkey.id

Use https://id.bkey.id as your issuer. auth.bkey.id and api.bkey.id also answer OIDC discovery, but the document they return declares "issuer": "https://id.bkey.id". OIDC Discovery §4.3 requires the document’s issuer to equal the one you configured, so pointing a client at either alias fails with issuer_mismatch before the first redirect.Register and authenticate against the same issuer. A client registered on production is unknown to staging, and vice versa.

Next.js — the five-line version

@bkey/login ships an Auth.js provider preset. Auth.js handles discovery, PKCE, state, nonce, and EdDSA id_token verification.
auth.ts
Auth.js also needs AUTH_SECRET set — any high-entropy string; it encrypts the session cookie. session.user.id is the user’s bkey ID. A complete working app is in examples/typescript/login-with-bkey-nextjs.

Any framework — the core helpers

handleCallback validates everything before returning: state (CSRF), the PKCE verifier, the id_token signature against BKey’s published JWKS, plus issuer, audience, expiry, and nonce replay. You do not need our SDK — BKey is a standards-compliant OIDC provider and any OIDC client library can drive this flow. Discovery already advertises token_endpoint_auth_methods_supported (none, client_secret_post) and id_token_signing_alg_values_supported (EdDSA). One required setting is not in the document:
  • PKCE with S256 is required at /authorize. code_challenge_methods_supported is absent, so a library configured purely from discovery will omit code_challenge and fail (#50).
A separate registration-time footgun: RFC 7591 defaults to client_secret_basic, which the registration endpoint rewrites to client_secret_post. A client that then sends Basic cannot redeem codes. Send token_endpoint_auth_method: "client_secret_post" at registration (as in the request above). The SDK sets both of those for you.

Signing out

Clearing your own session ends the sign-in. BKey is a stateless OP: it keeps no browser session or SSO cookie of its own, so there is no “still signed in at BKey” state to worry about. Every /authorize request triggers a fresh biometric approval on the user’s phone — a user who signs in again always approves again. That means the ordinary case is simple: destroy your own session and you are done. Two endpoints exist for the cases that aren’t ordinary, and it’s worth being precise about which does what: /oauth/revoke invalidates a token. If you are holding an access or refresh token and want it to stop working — the user asked you to disconnect, you are cleaning up after a leak — this is the call that actually ends something. /oauth/end_session implements the RP-Initiated Logout redirect contract, and only that. It verifies your id_token_hint, then redirects to a registered post_logout_redirect_uri. It does not revoke tokens. Use it when you want the spec-standard logout hand-off; use /oauth/revoke when you want tokens dead.
postLogoutRedirectUri must be one of the post_logout_redirect_uris you registered, and id_token_hint is required — without it BKey renders a confirmation page rather than redirecting. The Auth.js snippet above keeps that id_token on the jwt token as idToken, not on session (which the browser can read at /api/auth/session).

What you get back

The id_token carries exactly one identity claim: sub, a stable pseudonymous identifier for that user.
Treat sub as an opaque string and store it as your user key. Do not parse it, and do not constrain its length or character set — the format is not part of the contract. No name, email, or phone number is shared; collect anything else you need in your own onboarding, on first login. One privacy consequence worth stating plainly: sub is stable for a user and the same value is issued to every relying party (subject_types_supported is ["public"]). Two sites that both use Login with bkey can therefore determine they are talking to the same person. Pairwise subject identifiers, which would prevent that, are not currently offered.

What the user sees

On mobile, the consent page shows a button that opens the bkey app directly. This is the reliable path today. On desktop, it shows a pairing code and a QR code to scan from the phone. Note that the QR currently encodes a custom URL scheme rather than an HTTPS Universal Link, so camera scanning may not hand the request to the app — see #52. Approving from the phone works regardless. Either way the user confirms the on-screen pairing code matches the one on their phone, then approves with their face. Matching the pairing code proves the phone and this browser are in the same ceremony, so an attacker who starts a sign-in on their own machine cannot complete it with someone else’s approval. It does not prove which site is requesting the sign-in — a proxying page can display the genuine code and the comparison still succeeds.

Installing the SDK

Requires Node 20 or newer. The core helpers are a thin wrapper over standard OIDC, so you can also integrate with any OIDC client library and skip the SDK entirely.

See also