How do I add passkeys to my website?
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:
- 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.
- 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. - The browser returns the new credential: its id, its public key and the signed challenge.
- Your server checks the challenge, the origin and the RP ID, then stores the public key with the account.
Authentication signs someone in:
- Your server makes a new challenge.
- The browser calls
navigator.credentials.get({ publicKey }). The person picks a passkey and confirms with their face, fingerprint or PIN. - The device signs the challenge with the private key.
- 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()withmediation: "conditional"on page load, withoutallowCredentials, 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 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:
{ "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 (
exampleinexample.co.uk). Allexample.*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.
- Create a platform in the developer console and add your domains, as many as you have.
- Add the two routes from the quickstart: a redirect to loginos with
stateand PKCE, and a callback that exchanges the code on your server. - If you care how someone signed in, read
amrin 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
- PasskeysHow hard is it to implement WebAuthn myself?The core is not hard: with a maintained library such as SimpleWebAuthn, registering and checking a passkey takes a day or two. The hard part is everything around it: choosing the RP ID, storing challenges and credentials, several passkeys per person, autofill, account recovery, fallbacks and testing across browsers and password managers. Plan for weeks, not days, or use a provider that has passkeys built in, like loginos.7 min read
- PasskeysIs there a service that handles passkey login for me?Yes. Passkey add-ons such as Corbado Connect, the Hanko Passkey API and Bitwarden Passwordless.dev add passkeys to the login you already have, while full auth providers such as Auth0, Clerk, Stytch, Descope and Amazon Cognito run all of sign-in, passkeys included. Compare fallbacks, recovery, domains and data export first. With loginos, your app handles one OpenID Connect callback, and the ID token says whether a passkey was used.6 min read
- PasskeysHow do I implement passwordless login in a web app?You implement passwordless login by replacing the password with something the person has: a passkey, a one-time link or code sent by email or SMS, or an existing Google or Apple account. Passkeys are the only common method that resists phishing, so offer them first and keep an email link or code as the fallback. With loginos you get passkeys with a password fallback, but no email or social login.7 min read
- Sign-in across domainsHow do I share a login session across multiple domains?You can’t share a session cookie between two different domains, because browsers only send a cookie to the domain that set it. Instead, each domain keeps its own session and gets it from one central login, an OpenID Connect provider. Someone who is already signed in there is sent straight back with a one-time code, without seeing a form.6 min read