Synq Docs
Guides

Passkeys

Sign people in with passkeys, let them sign up with one, and add one to an account.

Every brand offers passkeys. Synq checks every passkey ceremony itself, at the brand's issuer:

  • The relying party is the issuer's host, so a passkey made at one brand never works at another.
  • User verification (a fingerprint, a face or a PIN) is required.
  • A passkey whose signature counter goes backwards, a sign of a cloned authenticator, is refused.
  • amr is swk for a synced passkey or hwk for one bound to its device, with user, and acr is urn:synq:loa:2, so your API can require a passkey for something sensitive.

Synq keeps only each passkey's public key. A person lists their passkeys through the account API.

Signing in and signing up

The sign-in page has a "Use a passkey" button, and its email field offers the browser's saved passkeys as the person types. Where the brand lets people sign up, the sign-up screen makes an account whose only way in is a new passkey: no email address is needed. Add synq_method=passkey to the authorization request to open the page at passkeys.

When your app asks for a fresh sign-in (prompt=login or max_age) and the person signed in has a passkey for this sign-in page, synced or not, the page leads with "Use your passkey", ahead of the other ways in: a passkey always shows a sign-in made just now, with nothing to type and no email to wait for. So does the step a sign-in provider's older session leads to ("Confirm it's you").

Asking for a passkey before something sensitive (step-up)

A passkey sign-in is acr urn:synq:loa:2: the passkey and the person's fingerprint, face or screen lock are two factors. Every other way in is urn:synq:loa:1. Before something sensitive (a payment, a change of email), your app can ask for loa:2 with acr_values=urn:synq:loa:2 on the authorization request:

  • Signed in below loa:2, with a passkey here: the page asks for the passkey alone, saying the app asks the person to confirm it's them with it. The ID token then says urn:synq:loa:2. With prompt=none the answer is login_required.
  • Signed in at loa:2 already: nothing is asked, prompt=none included.
  • No passkey: nothing is asked, and the ID token says urn:synq:loa:1. acr_values is a request, not a requirement (OpenID Connect Core 5.5.1.1), so your app checks acr in the ID token and, if it must have loa:2, sends the person to add a passkey first.

acr_values=urn:synq:loa:2 urn:synq:loa:1 takes either, and asks for nothing more.

Insisting on a passkey

When your app must never go on below loa:2, ask for acr as an essential claim through the claims parameter instead (OpenID Connect Core 5.5.1.1):

claims={"id_token":{"acr":{"essential":true,"values":["urn:synq:loa:2"]}}}
  • The page offers the passkey alone, whoever is signing in, and says the app needs a passkey sign-in.
  • Someone without a passkey here can only go back to the app. The app gets error=unmet_authentication_requirements, never a token at loa:1. So does any sign-in that ends below it, whichever way in it took.
  • Signed in at loa:2 already: nothing is asked, prompt=none included. Below it, prompt=none answers login_required.

With @synqauth/js, pass acr: { values: ['urn:synq:loa:2'], essential: true } to authorizationUrl or signIn. handleCallback then refuses an ID token below it with unmet_authentication_requirements as well. Without essential, the option sends acr_values.

The claims parameter never widens what your app learns: a claim it names is returned only when the request's scopes give it anyway (email needs the email scope).

Adding a passkey to an account

  • On the hosted pages: send the person to sign in with synq_intent=add-passkey and prompt=login (the intent is refused without it). Once they've signed in, a page offers to make a passkey, or goes on without one. Only this intent shows that page. Someone who has a passkey here already is told so, and offered one for another device.
  • From your own page, through the account API: start making a passkey, hand the options to the browser's WebAuthn API, then keep it. Both need a sign-in from the last 5 minutes whose time Synq proved; otherwise they answer fresh_sign_in_required, and you send the person to sign in with max_age=300. Your page's origin must be one of the calling app's own redirect URI origins, which the issuer lists at /.well-known/webauthn, among the first 5 sites browsers read there (see Sign in from your own UI). Browsers without related origins (before Chrome and Edge 128, Safari 18 and Firefox 152) need the hosted page instead.

Adding a passkey emails a notice to the person's verified address. An account whose address nobody verified takes no new passkey until it's verified.

Removing a passkey

The person removes one (also with a fresh sign-in), never their last way to sign in (last_sign_in_method). The tenant removes one for a lost or stolen device, even the last, then ends the user's sessions if it should.

On this page