# How do I add passkeys to my website?

> 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](https://developer.mozilla.org/en-US/docs/Web/API/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:

| Column | Why you keep it |
| --- | --- |
| Credential id | Finds the passkey at sign-in. Store it as base64url text, with an index. |
| Public key | Checks every signature. Store the raw bytes. |
| Signature counter | Helps spot cloned security keys. Synced passkeys usually send 0. |
| Transports | Tells the browser how to reach the authenticator, like `internal`, `hybrid` or `usb`. |
| Backup flags | Whether the passkey can sync (backup eligible) and whether it is synced now (backed up). |
| AAGUID, name, dates | Lets 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:

| Language | Library |
| --- | --- |
| Node.js and TypeScript | SimpleWebAuthn: `@simplewebauthn/server` and `@simplewebauthn/browser` |
| Python | py_webauthn from Duo Labs, `webauthn` on PyPI |
| Go | go-webauthn, `github.com/go-webauthn/webauthn` |
| Java | java-webauthn-server from Yubico, or WebAuthn4J |
| PHP | `web-auth/webauthn-lib`, part of the WebAuthn Framework, with a Symfony bundle |

Here is the server side with [SimpleWebAuthn](https://simplewebauthn.dev/docs/packages/server) 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.

```js title="Register"
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 });
});
```

```js title="Sign in"
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
// rpID and expectedOrigin as in the Register tab.

app.post("/passkeys/login/options", async (req, res) => {
  const options = await generateAuthenticationOptions({ rpID }); // no allowCredentials: any passkey for this site
  req.session.challenge = options.challenge;
  res.json(options);
});

app.post("/passkeys/login", async (req, res) => {
  const expectedChallenge = req.session.challenge;
  delete req.session.challenge;
  const passkey = await db.passkeys.find(String(req.body.id));
  if (!passkey) return res.status(401).json({ ok: false });

  const result = await verifyAuthenticationResponse({
    response: req.body,
    expectedChallenge,
    expectedOrigin,
    expectedRPID: rpID,
    credential: { id: passkey.id, publicKey: passkey.publicKey, counter: passkey.counter, transports: passkey.transports },
  }).catch(() => null);
  if (!result?.verified) return res.status(401).json({ ok: false });

  await db.passkeys.update(passkey.id, { counter: result.authenticationInfo.newCounter, lastUsedAt: new Date() });
  req.session.userId = passkey.userId; // your own session
  res.json({ ok: true });
});
```

```js title="Browser"
import { browserSupportsWebAuthnAutofill, startAuthentication, startRegistration } from "@simplewebauthn/browser";

const post = (url, body) =>
  fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body ?? {}) })
    .then((r) => r.json());

// Account settings: the "Add a passkey" button.
async function addPasskey() {
  const optionsJSON = await post("/passkeys/register/options");
  await post("/passkeys/register", await startRegistration({ optionsJSON }));
}

// Sign-in page, on load: passkey autofill. The username field needs autocomplete="username webauthn".
if (await browserSupportsWebAuthnAutofill()) {
  const optionsJSON = await post("/passkeys/login/options");
  const { ok } = await post("/passkeys/login", await startAuthentication({ optionsJSON, useBrowserAutofill: true }));
  if (ok) location.assign("/");
}
```

## 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`:

```json title="/.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](https://passkeys.dev/docs/advanced/related-origins/) 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](https://loginos.io/answers/share-login-session-across-domains.md).

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

1. Create a platform in the [developer console](https://loginos.io/developer) and add your domains, as many as you have.
2. Add the two routes from the [quickstart](https://loginos.io/docs/quickstart.md): 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](https://loginos.io/docs/oidc.md#claims) 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](https://loginos.io/answers/implement-webauthn-yourself.md) 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.

---

Updated 2026-10-14. Web page: https://loginos.io/answers/add-passkeys-to-website
