loginos

How do I add passkeys to my website?

loginos teamUpdated 7 min read

Short answer

To add passkeys, you use the WebAuthn browser API with a server library. Your server sends a random challenge, the browser calls navigator.credentials.create() to make a key pair, and you store the public key. To sign in, the browser signs a new challenge with navigator.credentials.get() and your server checks the signature. With loginos, passkeys live on loginos.io and need no WebAuthn code on your domains.

You want people to sign in with Face ID, a fingerprint or a security key instead of a password. A passkey is a key pair, made with the Web Authentication API (WebAuthn), that only works on the site it was made for. The person’s device or password manager keeps the private key, and your server keeps the public key.

Adding passkeys means adding two exchanges between the browser and your server: one creates a passkey, the other signs in with it. A library does the cryptography. The decisions stay with you: where challenges and passkeys are stored, how autofill works, and which domain the passkeys belong to.

How do passkey registration and sign-in work?

WebAuthn is a W3C standard. Its current version, Level 3, became a W3C Recommendation in August 2026, and every major browser supports the API. Each passkey is tied to an RP ID (relying party ID): the domain it belongs to, like example.com.

Registration creates a passkey:

  1. Your server makes a random challenge, a one-time value the device must sign. It sends the challenge with your RP ID, the person’s user handle and the passkeys they already have.
  2. The browser calls navigator.credentials.create({ publicKey }). The device asks for Face ID, a fingerprint or the screen lock, then makes a new key pair.
  3. The browser returns the new credential: its id, its public key and the signed challenge.
  4. Your server checks the challenge, the origin and the RP ID, then stores the public key with the account.

Authentication signs someone in:

  1. Your server makes a new challenge.
  2. The browser calls navigator.credentials.get({ publicKey }). The person picks a passkey and confirms with their face, fingerprint or PIN.
  3. The device signs the challenge with the private key.
  4. Your server finds the passkey by its credential id and checks the signature with the stored public key. It also checks the challenge, origin and RP ID, and saves the new signature counter.

The browser only offers a passkey on its RP ID and that domain’s subdomains, so a look-alike site gets nothing to steal. Your database only holds public keys, so a leak gives an attacker nothing to sign in with.

What do you need to build?

Passkeys need four pieces on top of your existing accounts.

Challenge storage. Keep each challenge on the server, in the session or a table, and delete it after one use. The spec asks for at least 16 random bytes and recommends timeouts of five to ten minutes.

A passkey table. People should be able to have several passkeys, one per device or password manager, so give passkeys their own table:

ColumnWhy you keep it
Credential idFinds the passkey at sign-in. Store it as base64url text, with an index.
Public keyChecks every signature. Store the raw bytes.
Signature counterHelps spot cloned security keys. Synced passkeys usually send 0.
TransportsTells the browser how to reach the authenticator, like internal, hybrid or usb.
Backup flagsWhether the passkey can sync (backup eligible) and whether it is synced now (backed up).
AAGUID, name, datesLets people tell their passkeys apart in their settings.

Each account also needs a user handle: random bytes, at most 64, never the email address. The device stores it with the passkey and returns it at sign-in.

Browser calls. navigator.credentials.create() and navigator.credentials.get() take and return binary data, while your server speaks JSON. Libraries convert between the two with base64url.

Passkey autofill. With conditional mediation, the browser lists passkeys in the username field’s autofill menu, next to saved passwords. You need three things:

  • autocomplete="username webauthn" on the username input.
  • A check that PublicKeyCredential.isConditionalMediationAvailable() returns true. Chrome 108, Safari 16 and Firefox 119 added it.
  • A call to navigator.credentials.get() with mediation: "conditional" on page load, without allowCredentials, so every passkey for your site can show.

The call waits until the person picks a passkey, so people who type a password never see a prompt.

Which library should you use?

Use a maintained library instead of parsing attestation objects and checking signatures yourself. Each of these had a release in September or October 2026:

LanguageLibrary
Node.js and TypeScriptSimpleWebAuthn: @simplewebauthn/server and @simplewebauthn/browser
Pythonpy_webauthn from Duo Labs, webauthn on PyPI
Gogo-webauthn, github.com/go-webauthn/webauthn
Javajava-webauthn-server from Yubico, or WebAuthn4J
PHPweb-auth/webauthn-lib, part of the WebAuthn Framework, with a Symfony bundle

Here is the server side with SimpleWebAuthn 14, which needs Node.js 22 or later. Each options route keeps its challenge in the session, and each verify route uses it once. excludeCredentials stops the same password manager from being registered twice.

import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";

const rpID = "example.com";
const expectedOrigin = "https://example.com";

app.post("/passkeys/register/options", async (req, res) => {
  const user = await db.users.find(req.session.userId); // must be signed in
  if (!user) return res.status(401).json({ ok: false });
  const existing = await db.passkeys.forUser(user.id);
  const options = await generateRegistrationOptions({
    rpName: "Example",
    rpID,
    userName: user.username,
    excludeCredentials: existing.map((p) => ({ id: p.id, transports: p.transports })),
    authenticatorSelection: { residentKey: "required" }, // a discoverable credential: a passkey
  });
  req.session.challenge = options.challenge;
  res.json(options);
});

app.post("/passkeys/register", async (req, res) => {
  const expectedChallenge = req.session.challenge;
  delete req.session.challenge; // each challenge works once
  if (!req.session.userId) return res.status(401).json({ ok: false });
  const result = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin,
    expectedRPID: rpID,
  }).catch(() => null); // it throws when a check fails
  if (!result?.verified) return res.status(400).json({ ok: false });

  const { credential, credentialDeviceType, credentialBackedUp, aaguid } = result.registrationInfo;
  await db.passkeys.insert({
    userId: req.session.userId,
    id: credential.id, // base64url
    publicKey: credential.publicKey, // bytes
    counter: credential.counter,
    transports: credential.transports,
    backupEligible: credentialDeviceType === "multiDevice",
    backedUp: credentialBackedUp,
    aaguid,
  });
  res.json({ ok: true });
});

Can one passkey work on several domains?

Not by default. The RP ID must be the page’s own domain or a parent of it. A page on app.example.com can use the RP ID example.com, so one passkey covers all your subdomains. A page on example.net can’t, so a passkey made for example.com never shows up there.

Related Origin Requests relax that rule for a short list of sites. You publish the other origins at https://example.com/.well-known/webauthn, served as application/json:

/.well-known/webauthn
{ "origins": ["https://example.net", "https://example.co.uk", "https://examplerewards.com"] }

A page on example.net can then ask for the RP ID example.com, and the browser fetches the file to confirm. Mind the limits:

  • Five labels. Browsers count labels, the name in front of the public suffix (example in example.co.uk). All example.* sites count as one label, and no known browser accepts more than five.
  • Browser support. Chrome and Edge 128, Safari 18 and Firefox 152 support it. Older browsers reject the request with a SecurityError, so you need a fallback.
  • One RP ID from the start. A passkey keeps the RP ID it was made with. Pick the shared one before launch, or record each passkey’s RP ID.
  • Web only. Native apps use Android Digital Asset Links or Apple associated domains instead.

The passkeys.dev guide calls Related Origin Requests a tool for cases where federation isn’t possible. With many domains, or customers who bring their own, put the passkeys on one login domain and send people there with OpenID Connect, as in sharing one sign-in across domains.

How loginos does it

loginos is an OpenID Connect provider, and the passkeys live on loginos.io. People create them with Face ID, a fingerprint or a security key, and Apple, Google, Microsoft or a password manager syncs them. Because each passkey belongs to loginos.io, the same passkey signs people in on every domain of your platform, and on every other platform that uses loginos. Your domains need no WebAuthn code, no challenge storage and no passkey table.

Browser
Your server
loginos
1/6Your site sends the browser to loginos with your app key, a state and a PKCE challenge.
  1. Create a platform in the developer console and add your domains, as many as you have.
  2. Add the two routes from the quickstart: a redirect to loginos with state and PKCE, and a callback that exchanges the code on your server.
  3. If you care how someone signed in, read amr in the ID token: ["hwk"] after a passkey and ["pwd"] after a password. The claims reference lists the rest.

The sign-in page offers passkey autofill, and passwords still work for people who prefer them. The trade-off is that sign-in happens on loginos.io, not on your own domain. If your passkeys must belong to your own domain, build them with a library as above, and read how hard WebAuthn is to run yourself first.

Frequently asked questions

Do passkeys work on localhost?

Yes. WebAuthn treats http://localhost as a valid origin, with localhost as the RP ID. Other plain http addresses don’t work, so test staging sites over HTTPS. Keep the RP ID and the expected origins on the server in step with the address you test, or every check fails.

Can I add passkeys to a login that already uses passwords?

Yes. Let signed-in people create a passkey from their account settings, then turn on passkey autofill on the sign-in page. Chrome 136 on desktop and Safari 18 can also create a passkey automatically right after a password sign-in, when you call navigator.credentials.create() with mediation: "conditional" and the password manager allows it.

What happens when someone loses their phone?

A synced passkey survives, because it comes back with their Apple, Google or password manager account on the new device. A device-bound passkey, like one on a security key, is gone with the device. Let people register more than one passkey, and keep a way back in that doesn’t depend on the lost device.

Should I check attestation?

Usually not. Attestation proves an authenticator’s make and model, which matters for some enterprise and government systems. A consumer site can ask for attestation: "none", the default in SimpleWebAuthn, and MDN advises against keeping allowlists of authenticators.

One account for every domain you run

loginos is an OpenID Connect provider with passkeys, a user pool shared by all of your domains, roles in every token, and account linking for the users you already have. Add your platform and its domains in a few minutes.

Keep reading

All answers