What a Passkey Is and How to Implement It
Passkeys are one of the most important shifts in application authentication in a long time.
If you have built login systems before, you already know the usual tradeoffs. Passwords are familiar, but they are weak, reused, phished, leaked, and expensive to support. Magic links are often easier to use, but they still depend on email and are not always ideal for high-assurance authentication. SMS OTP is widely understood, but it comes with real security and delivery concerns.
Passkeys are a better direction.
They let users sign in with the same device-level gesture they already trust, like Face ID, Touch ID, Windows Hello, or a device PIN, while the application authenticates them with public key cryptography instead of a shared secret. That change matters. It removes the password from the flow entirely and makes phishing much harder.
This post is for implementers. We will cover what a passkey actually is, how the underlying authentication flow works, why more teams should adopt them, and what implementation looks like in a real application.
What is a passkey?
A passkey is a credential based on the WebAuthn and FIDO2 standards that allows a user to authenticate with a public-private key pair instead of a password.
At a high level:
- the authenticator creates a key pair
- the private key stays on the user's device or secure authenticator
- the public key is shared with your server and stored for future verification
- when the user signs in, your server sends a challenge
- the device signs that challenge with the private key
- your server verifies the signature with the public key it already has
That means your application never stores a password hash for that login method, and the user never needs to type a secret that can be phished or reused.
In practice, a passkey usually feels like this:
- A user clicks "Sign in with passkey"
- The browser asks the platform authenticator to verify the user
- The user uses Face ID, Touch ID, Windows Hello, Android biometrics, or a device PIN
- The browser returns a signed assertion
- Your backend verifies it and signs the user in
The important thing is that biometrics are not the credential your app receives. The fingerprint or face scan is only used locally to unlock the private key. Your server sees a cryptographic proof, not biometric data.
Why passkeys are a big deal
Passkeys are attractive for two reasons at the same time: security and user experience.
Passwords are often weak because humans have to remember them. As a result, many security rules are really patches around that fact. Complexity rules, resets, lockouts, MFA prompts, anti-phishing training, credential stuffing detection, and breach monitoring all exist partly because the first factor is fragile.
Passkeys improve the base layer.
Security benefits
Passkeys help because they are:
- resistant to phishing, since the credential is scoped to the relying party and cannot be replayed on a fake domain
- not shared secrets, which means there is no password to steal from the user and no password hash to steal from your database
- unique per application, reducing credential reuse
- protected by secure hardware or platform key storage in many environments
- hard to brute force because the login is based on signed challenges rather than guessed secrets
User experience benefits
Passkeys also reduce friction:
- no password creation step
- no password reset flow
- no need to remember another secret
- faster repeat sign-in on trusted devices
- often fewer support tickets around login issues
This is one of the rare places in software where the more secure option can also be the more pleasant one.
The mental model implementers should use
A lot of confusion around passkeys comes from treating them like "a better password field."
They are not that.
A passkey system is closer to a key registration and challenge verification system.
There are two main flows:
- registration, where the user creates a new credential for your app
- authentication, where the user proves possession of that credential
The passkey registration flow
During registration, your backend prepares a PublicKeyCredentialCreationOptions object and sends it to the browser. This includes things like:
- the relying party ID and name
- the user ID and username
- a cryptographic challenge
- the algorithms you support
- authenticator preferences
- optional attestation settings
The browser passes those options into navigator.credentials.create().
If the user completes the platform prompt successfully, the browser returns a new credential object. Your frontend sends that to your backend. The backend verifies the result and stores the credential ID, public key, sign counter, and metadata associated with the user.
The passkey authentication flow
Authentication is similar, but instead of creating a key pair, the authenticator uses the existing private key to sign a challenge.
Your backend creates PublicKeyCredentialRequestOptions. The browser passes those options into navigator.credentials.get(). The authenticator verifies the user locally, signs the challenge, and returns the assertion. Your backend verifies the signature against the stored public key for that credential.
The pieces you need in an application
A good passkey implementation usually has these pieces:
On the frontend
- a registration button and sign-in button
- endpoints to fetch registration and authentication options
- calls to
navigator.credentials.create()andnavigator.credentials.get() - serialization logic for binary values like ArrayBuffers
- error handling for cancellation and unsupported browsers
On the backend
- endpoints to generate registration options
- endpoints to verify registration responses
- endpoints to generate authentication options
- endpoints to verify authentication responses
- persistent storage for passkey credentials
- session creation after successful verification
In your database
You generally need to store:
- user ID
- credential ID
- public key
- sign count
- transports if you want them
- credential device type if relevant to your auth model
- backup eligibility and backup state if your library exposes them
- created at and last used timestamps
What the browser code looks like
You can build the raw WebAuthn flow yourself, but most teams use a library to avoid subtle bugs in binary encoding, challenge handling, and verification. A common setup is:
- frontend: browser WebAuthn APIs or a helper package
- backend: a server-side WebAuthn library for generating options and verifying responses
Here is a simplified frontend example.
Frontend registration example
type RegistrationOptionsResponse = {
options: PublicKeyCredentialCreationOptionsJSON
}
type VerifyRegistrationResponse = {
verified: boolean
}
async function registerPasskey(email: string) {
const optionsResp = await fetch("/auth/passkey/register/options", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
})
if (!optionsResp.ok) {
throw new Error("Failed to get registration options")
}
const { options }: RegistrationOptionsResponse = await optionsResp.json()
const credential = await startRegistration(options)
const verifyResp = await fetch("/auth/passkey/register/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, credential }),
})
if (!verifyResp.ok) {
throw new Error("Failed to verify registration")
}
const result: VerifyRegistrationResponse = await verifyResp.json()
if (!result.verified) {
throw new Error("Registration could not be verified")
}
return result
}
Frontend authentication example
type AuthenticationOptionsResponse = {
options: PublicKeyCredentialRequestOptionsJSON
}
type VerifyAuthenticationResponse = {
verified: boolean
redirectTo?: string
}
async function loginWithPasskey(email?: string) {
const optionsResp = await fetch("/auth/passkey/authenticate/options", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
})
if (!optionsResp.ok) {
throw new Error("Failed to get authentication options")
}
const { options }: AuthenticationOptionsResponse = await optionsResp.json()
const credential = await startAuthentication(options)
const verifyResp = await fetch("/auth/passkey/authenticate/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ credential }),
})
if (!verifyResp.ok) {
throw new Error("Failed to verify authentication")
}
const result: VerifyAuthenticationResponse = await verifyResp.json()
if (!result.verified) {
throw new Error("Authentication could not be verified")
}
return result
}
In the examples above, startRegistration and startAuthentication are helper functions many teams import from a WebAuthn library rather than writing the low-level binary conversion manually.
What the backend code looks like
Here is a simplified Node and Express example using a WebAuthn helper library. The exact function names may vary depending on the package you choose, but the structure is consistent.
Generate registration options
import express from "express"
import {
generateRegistrationOptions,
} from "@simplewebauthn/server"
const router = express.Router()
router.post("/auth/passkey/register/options", async (req, res) => {
const { email } = req.body
const user = await findOrCreateUserByEmail(email)
const existingCredentials = await getUserPasskeys(user.id)
const options = await generateRegistrationOptions({
rpName: "Your App",
rpID: "yourapp.com",
userName: user.email,
userID: user.id,
attestationType: "none",
excludeCredentials: existingCredentials.map((credential) => ({
id: credential.credentialID,
transports: credential.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
})
await saveRegistrationChallenge(user.id, options.challenge)
res.json({ options })
})
Verify registration response
import {
verifyRegistrationResponse,
} from "@simplewebauthn/server"
router.post("/auth/passkey/register/verify", async (req, res) => {
const { email, credential } = req.body
const user = await findUserByEmail(email)
if (!user) {
return res.status(404).json({ verified: false, error: "User not found" })
}
const expectedChallenge = await getRegistrationChallenge(user.id)
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge,
expectedOrigin: "https://yourapp.com",
expectedRPID: "yourapp.com",
})
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ verified: false })
}
const { credentialPublicKey, credentialID, counter } =
verification.registrationInfo
await saveUserPasskey({
userId: user.id,
credentialID,
publicKey: credentialPublicKey,
counter,
})
await clearRegistrationChallenge(user.id)
return res.json({ verified: true })
})
Generate authentication options
import {
generateAuthenticationOptions,
} from "@simplewebauthn/server"
router.post("/auth/passkey/authenticate/options", async (req, res) => {
const { email } = req.body
let allowCredentials: Array<{
id: Uint8Array
transports?: AuthenticatorTransportFuture[]
}> = []
if (email) {
const user = await findUserByEmail(email)
if (user) {
const credentials = await getUserPasskeys(user.id)
allowCredentials = credentials.map((credential) => ({
id: credential.credentialID,
transports: credential.transports,
}))
}
}
const options = await generateAuthenticationOptions({
rpID: "yourapp.com",
userVerification: "preferred",
allowCredentials,
})
await saveAuthenticationChallenge(options.challenge)
res.json({ options })
})
Verify authentication response
import {
verifyAuthenticationResponse,
} from "@simplewebauthn/server"
router.post("/auth/passkey/authenticate/verify", async (req, res) => {
const { credential } = req.body
const storedPasskey = await findPasskeyByCredentialId(credential.id)
if (!storedPasskey) {
return res.status(404).json({ verified: false, error: "Passkey not found" })
}
const expectedChallenge = await getAuthenticationChallenge()
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge,
expectedOrigin: "https://yourapp.com",
expectedRPID: "yourapp.com",
credential: {
id: storedPasskey.credentialID,
publicKey: storedPasskey.publicKey,
counter: storedPasskey.counter,
transports: storedPasskey.transports,
},
})
if (!verification.verified) {
return res.status(400).json({ verified: false })
}
await updatePasskeyCounter(
storedPasskey.id,
verification.authenticationInfo.newCounter,
)
await createUserSession(storedPasskey.userId, res)
return res.json({ verified: true })
})
Important implementation details people often miss
Passkey implementations are not especially large, but they do have sharp edges. These are the details worth respecting.
1. Challenge handling matters
Challenges must be:
- unique
- short-lived
- tied to the current flow
- verified on the server
- cleared after use
Do not trust the browser to enforce this for you. The server is responsible for challenge lifecycle.
2. Origin and RP ID checks are critical
The browser and authenticator operate in a context tied to your domain. Your server must verify that the expected origin and relying party ID match what you intended.
If you get sloppy here, you undermine one of the biggest benefits of passkeys.
3. Binary encoding is annoying
Credential IDs, public keys, and authenticator responses contain binary data. If you build this yourself, expect to handle base64url conversion carefully on both client and server.
This is one of the best reasons to use a mature library.
4. User verification policy is a product choice
You will often see options like:
requiredpreferreddiscouraged
If you need stronger assurance, required may be appropriate. If you want the broadest compatibility, preferred is often the pragmatic place to start.
5. You still need account recovery thinking
Passkeys reduce pain, but they do not remove the need for recovery flows.
Users lose devices. Browsers change. Sync may fail. Enterprise users may switch laptops. You need a careful fallback strategy that does not quietly reintroduce weak security.
A common pattern is:
- passkeys as the preferred primary method
- verified email fallback for recovery
- step-up verification for sensitive changes
- the ability to register more than one passkey per account
Recommended product approach
If you are adding passkeys to an existing application, the best product move is usually not to force a total cutover on day one.
A practical rollout looks like this:
- Offer passkeys as an additional sign-in option
- Let existing users enroll from account settings
- Encourage multiple passkeys across devices
- Use passkeys as the default recommendation for new accounts
- Reduce dependence on passwords over time
That approach avoids locking users out while still moving your auth system in a stronger direction.
Where passkeys fit relative to other auth methods
Passkeys are not the only useful auth method, but they are increasingly the best primary choice for many applications.
Here is a plain comparison:
| Method | UX | Phishing resistance | Secret stored on server | Recovery complexity |
|---|---|---|---|---|
| Passwords | Familiar but frustrating | Low | Yes | Medium |
| Magic links | Simple | Medium | No password, but email is dependency | Medium |
| SMS OTP | Familiar | Low to medium | No password | Medium |
| Passkeys | Fast once enrolled | High | No shared secret | Medium |
The reason passkeys stand out is that they improve the underlying security model, not just the surface experience.
Who should implement passkeys now
Passkeys are especially worth adopting if you are building:
- SaaS products with repeat login behavior
- admin surfaces with elevated privileges
- consumer apps where password reset is a support burden
- products that want to be modern and security-forward by default
- apps that already support WebAuthn-capable browsers and devices
They are also a good fit for teams that want to build toward a passwordless future instead of continuing to harden a weak password-based past.
Common objections
"Our users will not understand passkeys"
Some will not at first. That is normal.
What helps is good product language. In many cases, users do not need a lecture on public key cryptography. They need clear prompts like:
- Sign in with your device
- Use Face ID or your fingerprint to continue
- Save a passkey for faster, more secure sign-in next time
If the experience is clean, the concept lands quickly.
"It sounds hard to implement"
It is more specialized than a password field, but it is not unreasonable. Most of the complexity is in:
- correct challenge generation and verification
- handling browser credential objects
- storing credential metadata properly
- designing sane recovery flows
With a good library and a focused implementation, this is very manageable.
"We still need something else for fallback"
Yes, and that is okay.
A strong passkey implementation is usually part of a broader auth system, not the entire system in isolation.
If you are building this today
A good implementation plan looks like this:
- Add passkey credential storage to your auth database
- Build registration and authentication option endpoints
- Build verification endpoints
- Add frontend flows for create and sign-in
- Create a recovery path that you trust
- Let users enroll multiple passkeys
- Start nudging new users toward passkey-first authentication
If you are already thinking in terms of passwordless architecture, passkeys are not just another feature. They are one of the clearest ways to reduce both user friction and security risk at the same time.
Final thought
A lot of authentication work over the last decade has been about compensating for the weaknesses of passwords.
Passkeys feel different because they improve the foundation itself.
They give implementers a cleaner model, users a better login experience, and security teams a stronger default story. That does not mean every edge case disappears. It does mean the direction is better, and for many modern applications, it is the direction worth taking now.
If you want to go deeper into building passwordless authentication systems and implementing flows like passkeys in real applications, take a look at the Seamless Auth docs. They are a good next step if you want to move from understanding the concept to actually wiring it into your stack.