Passkeys (22 Sep 2022)
This is an opinionated, “quick-start” guide to using passkeys as a web developer. It’s hopefully broadly applicable, but one size will never fit all authentication needs and this guide ignores everything that’s optional. So take it as a worked example, but not as gospel.
It doesn't use any WebAuthn libraries, it just assumes that you have access to functions for verifying signatures. That mightn't be optimal—maybe finding a good library is better idea—but passkeys aren't so complex that it's unreasonable for people to know what's going on.
This is probably a post that'll need updating over time, making it a bad fit for a blog, so maybe I'll move it in the future. But it's here for now.
Platforms for developing with passkeys include:
- Safari on iOS 16 or macOS 13.
- Chrome Canary (with
chrome://flags#webauthn-conditional-ui
set) on Windows 22H2. - Chrome Canary (with
chrome://flags#webauthn-conditional-ui
set) on macOS.
Database changes
Each user will need a passkey user ID. The
user ID identifies an account, but should
not contain any personally identifiable information (PII). You
probably already have a user ID in your system, but you should make one
specifically for passkeys to more easily keep it PII-free. Create a new
column in your users
table and populate it with large
random values for this purpose. (The following is in SQLite syntax so
you’ll need to adjust for other databases.)
/* SQLite can't set a non-constant DEFAULT when altering a table, only
* when creating it, but this is what we would like to write. */
ALTER TABLE users ADD COLUMN passkey_id blob DEFAULT(randomblob(16));
/* The CASE expression causes the function to be non-constant. */
UPDATE USERS SET passkey_id=hex(randomblob(CASE rowid WHEN 0
THEN 16
ELSE 16 END));
A user can only have a single password but can have multiple passkeys. So create a table for them:
CREATE TABLE passkeys (
id BLOB PRIMARY KEY,
NOT NULL,
username STRING BLOB,
public_key_spki BOOLEAN,
backed_up FOREIGN KEY(username) REFERENCES users(username));
Secure contexts
Nothing in WebAuthn works outside of a secure context, so if you’re not using HTTPS, go fix that first.
Enrolling existing users
When a user signs in with a password, you might want to prompt them to create a passkey on the local device for easier sign-in next time. First, check to see if their device has a local authenticator and that the browser is going to support passkeys:
if (!window.PublicKeyCredential ||
!(PublicKeyCredential as any).isConditionalMediationAvailable) {
return;
}
Promise.all([
as any).isConditionalMediationAvailable(),
(PublicKeyCredential
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()])
.then((values) => {
if (values.every(x => x === true)) {
promptUserToCreatePlatformCredential();
} })
(The snippets here are in TypeScript. It should be easy to convert them to plain Javascript if that’s what you need. You might notice several places where TypeScript’s DOM types are getting overridden because lib.dom.d.ts hasn’t caught up. I hope these cases will disappear in time.)
If the user accepts, ask the browser to create a local credential:
var createOptions : CredentialCreationOptions = {
publicKey: {
rp: {
// The RP ID. This needs some thought. See comments below.
id: SEE_BELOW,
// This field is required to be set to something, but you can
// ignore it.
name: "",
,
}
user: {
// `userIdBase64` is the user's passkey ID, from the database,
// base64-encoded.
id: Uint8Array.from(atob(userIdBase64), c => c.charCodeAt(0)),
// `username` is the user's username. Whatever they would type
// when signing in with a password.
name: username,
// `displayName` can be a more human name for the user, or
// just leave it blank.
displayName: "",
,
}
// This lists the ids of the user's existing credentials. I.e.
// SELECT id FROM passkeys WHERE username = ?
// and supply the resulting list of values, base64-encoded, as
// existingCredentialIdsBase64 here.
excludeCredentials: existingCredentialIdsBase64.map(id => {
return {
type: "public-key",
id: Uint8Array.from(atob(id), c => c.charCodeAt(0)),
;
},
})
// Boilerplate that advertises support for P-256 ECDSA and RSA
// PKCS#1v1.5. Supporting these key types results in universal
// coverage so far.
pubKeyCredParams: [{
type: "public-key",
alg: -7
, {
}type: "public-key",
alg: -257
,
}]
// Unused during registrations, except in some enterprise
// deployments. But don't do this during sign-in!
challenge: new Uint8Array([0]),
authenticatorSelection: {
authenticatorAttachment: "platform",
requireResidentKey: true,
,
}
// Three minutes.
timeout: 180000,
};
}
navigator.credentials.create(createOptions).then(
, handleCreationError); handleCreation
RP IDs
There are two levels of controls that prevent passkeys from being used on the wrong website. You need to know about this upfront to prevent getting stuck later.
“RP” stands for “relying party”. You (the website) are a “relying party” in authentication-speak. An RP ID is a domain name and every passkey has one that’s fixed at creation time. Every passkey operation asserts an RP ID and, if a passkey’s RP ID doesn’t match, then it doesn’t exist for that operation.
This prevents one site from using another’s passkeys. A passkey with an RP ID of foo.com can’t be used on bar.com because bar.com can’t assert an RP ID of foo.com. A site may use any RP ID formed by discarding zero or more labels from the left of its domain name until it hits an eTLD. So say that you’re https://www.foo.co.uk: you can assert www.foo.co.uk (discarding zero labels), foo.co.uk (discarding one label), but not co.uk because that hits an eTLD. If you don’t set an RP ID in a request then the default is the site’s full domain.
Our www.foo.co.uk example might happily be creating passkeys with the default RP ID but later decide that it wants to move all sign-in activity to an isolated origin, https://accounts.foo.co.uk. But none of the passkeys could be used from that origin! If would have needed to create them with an RP ID of foo.co.uk in the first place to allow that.
But you might want to be careful about always setting the most general RP ID because then usercontent.foo.co.uk could access and overwrite them too. That brings us to the second control mechanism. As you’ll see later, when a passkey is used to sign in, the browser includes the origin that made the request in the signed data. So accounts.foo.co.uk would be able to see that a request was triggered by usercontent.foo.co.uk and reject it, even if the passkey’s RP ID allowed usercontent.foo.co.uk to use it. But that mechanism can’t do anything about usercontent.foo.co.uk being able to overwrite them.
So either pick an RP ID and put it in the “SEE BELOW” placeholder, above. Or else don’t include the rp.id field at all and use the default.
Recording a passkey
When the promise from navigator.credentials.create
resolves successfully, you have a newly created passkey! Now you have to
ensure that it gets recorded by the server.
The promise will result in a PublicKeyCredential
object, the response
field of which is an AuthenticatorAttestationResponse
.
First, sanity check some data from the browser. Since this data isn’t
signed over in the configuration that we’re using, it’s fine to do this
check client-side.
const cdj = JSON.parse(
new TextDecoder().decode(cred.response.clientDataJSON));
if (cdj.type != 'webauthn.create' ||
'crossOrigin' in cdj) && cdj.crossOrigin) ||
((.origin != 'https://YOURSITEHERE') {
cdj// handle error
}
Call getAuthenticatorData()
and
getPublicKey()
on response
and send those
ArrayBuffers to the server.
At the server, we want to insert a row into the passkeys
table for this user. The authenticator
data is a fairly simple, binary format. Offset 32 contains the flags
byte. Sanity check that bit 7 is set and then extract:
- Bit 4 as the value of
backed_up
. (I.e.(authData[32] >> 4) & 1
.) - The big-endian, uint16 at offset 53 as the length of the credential ID.
- That many bytes from offset 55 as the value of
id
.
The ArrayBuffer that came from getPublicKey()
is the
value for public_key_spki
. That should be all the values
needed to insert the row.
Handling a registration exception
The promise from create()
might also result in an
exception. InvalidStateError
is special and means that a
passkey already exists for the local device. This is not an error, and
no error will have been shown to the user. They’ll have seen a UI just
like they were registering a passkey but the server doesn’t need to
update anything.
NotAllowedError
means that the user canceled the
operation. Other exceptions mean that something more unexpected
happened.
To test whether an exception is one of these values do something like:
function handleCreationError(e: Error) {
if (e instanceof DOMException) {
switch (e.name) {
case 'InvalidStateError':
console.log('InvalidStateError');
return;
case 'NotAllowedError':
console.log('NotAllowedError');
return;
}
}
console.log(e);
}
(But obviously don’t just log them to the console in real code.)
Signing in with autocomplete
Somewhere on your site you have username & password inputs. On
the username input
element, add webauthn
to
the autocomplete
attribute. So if you have:
<input type="text" name="username" autocomplete="username">
… then change that to …
<input type="text" name="username" autocomplete="username webauthn">
Autocomplete for passkeys works differently than for passwords. For the latter, when the user selects a username & password from the pop-up, the input fields are filled for them. Then they can click a button to submit the form and sign in. With passkeys, no fields are filled, but rather a pending promise is resolved. It’s then the site’s responsibility to navigate/update the page so that the user is signed in.
That pending promise must be set up by the site before the user
focuses the username field and triggers autocomplete. (Just adding the
webauthn
tag doesn’t do anything if there’s not a pending
promise for the browser to resolve.) To create it, run a function at
page load that:
- Does feature detection and, if supported,
- Starts a “conditional” WebAuthn request to produce the promise that will be resolved if the user selects a credential.
Here’s how to do the feature detection:
if (!window.PublicKeyCredential ||
!(PublicKeyCredential as any).isConditionalMediationAvailable) {
return;
}
as any).isConditionalMediationAvailable()
(PublicKeyCredential .then((result: boolean) => {
if (!result) {
return;
}
startConditionalRequest();
; })
Then, to start the conditional request:
var getOptions : CredentialRequestOptions = {
// This is the critical option that tells the browser not to show
// modal UI.
mediation: "conditional" as CredentialMediationRequirement,
publicKey: {
challenge: Uint8Array.from(atob(CHALLENGE_SEE_BELOW), c =>
.charCodeAt(0)),
c
rpId: SAME_AS_YOU_USED_FOR_REGISTRATION,
};
}
navigator.credentials.get(getOptions).then(
, handleSignInError); handleSignIn
Challenges
Challenges are random values, generated by the server, that are signed over when using a passkey. Because they are large random values, the server knows that the signature must have been generated after it generated the challenge. This stops “replay” attacks where a signature is captured and used multiple times.
Challenges are a little like a CSRF token: they should be large (16- or 32-byte), cryptographically-random values and stored in the session object. They should only be used once: when a sign-in attempt is received, the challenge should be invalidated. Future sign-in attempts will have to use a fresh challenge.
The snippet above has a value CHALLENGE_SEE_BELOW
which
is assumed to be the base64-encoded challenge for the sign-in. The
sign-in page might XHR
to get the challenge, or the challenge might be injected into the page’s
template. Either way, it must be generated at the server!
Handling sign-in
If the user selects a passkey then handleSignIn
will
be called with a PublicKeyCredential
object, the response
field of which is a AuthenticatorAssertionResponse
.
Send the ArrayBuffers rawId
,
response.clientDataJSON
,
response.authenticatorData
, and
response.signature
to the server.
At the server, first look up the passkey:
SELECT (username, public_key_spki, backed_up) FROM passkey WHERE id = ?
and give the value of rawId
for matching. The
id
column is a primary key, so there can either be zero or
one matching rows. If there are zero rows then the user is signing in
with a passkey that the server doesn’t know about—perhaps they deleted
it. This is an error, reject the sign-in.
Otherwise, the server now knows the claimed username and public key.
To validate the signature you’ll need to construct the signed data and
parse the public key. The public_key_spki
values from the
database are stored in SubjectPublicKeyInfo
format and most languages will have some way to ingest them. Here are
some examples:
- Java: java.security.spec.X509EncodedKeySpec
- .NET: System.Security.Cryptography.ECDsa.ImportSubjectPublicKeyInfo
- Go: crypto/x509.ParsePKIXPublicKey
Your languages’s crypto library should provide a function that takes
a signature and some signed data and tells you whether that signature is
valid for a given public key. For the signature, pass in the value of
the signature
ArrayBuffer that the client sent. For the
signed data, calculate the SHA-256 hash of clientDataJSON
and append it to the contents of authenticatorData
. If the
signature isn’t valid, reject the sign-in.
But there are still a bunch of things that you need to check!
Parse the clientDataJSON
as UTF-8 JSON and check
that:
- The
type
member is “webauthn.get”. - The
challenge
member is equal to the base64url encoding of the challenge that the server gave for this sign-in. - The
origin
member is equal to your site’s sign-in origin (e.g. a string like “https://www.example.com”). - The
crossOrigin
member, if present, is false.
There’s more! Take the authenticatorData
and check
that:
- The first 32 bytes are equal to the SHA-256 hash of the RP ID that you’re using.
- That bit zero of the byte at offset 32 is one. I.e.
(authData[32] & 1) == 1
. This is the user presence bit that indicates that a user approved the signature.
If all those checks work out then sign in the user whose passkey it was. I.e. set a cookie and respond to the running Javascript so that it can update the page.
If the stored value of backed_up
is not equal to
(authData[32] >> 4) & 1
then update that in the
database.
Removing passwords
Once a user is using passkeys to sign in, great! But if they were upgraded from a password then that password is hanging around on the account, doing nothing useful yet creating risk. It would be good to ask the user about removing the password.
Doing this is reasonable if the account has a backed-up passkey. I.e.
if
SELECT 1 FROM passkeys WHERE username = ? AND backed_up = TRUE
has results. A site might consider prompting the user to remove the
password on an account when they sign in with a passkey and have a
backed-up one registered.
Registering new, passkey-only users
For sign ups of new users, consider making them passkey-only if the feature detection (from the section on enrolling users) is happy.
When enrolling users where a passkey will be their only sign-in
method you really want the passkey to end up “in their pocket”, i.e. on
their phone. Otherwise they could have a passkey on the computer that
they signed-up with but, if it’s not syncing to their phone, that’s not
very convenient. There is not, currently, a great answer for this I’m
afraid! Hopefully, in a few months, calling
navigator.credentials.create()
with
authenticatorSelection.authenticatorAttachment
set to
cross-platform
will do the right thing. But with iOS 16
it’ll exclude the platform authenticator.
So, for now, do that on all platforms except for iOS/iPadOS, where
authenticatorAttachment
should continue to be
platform
.
(I’ll try and update this section when the answer is simplier!)
Settings
If you’ve used security keys with any sites then you’ll have noticed that they tend to list registered security keys in their account settings, have users name each one, show the last-used time, and let them be individually removed. You can do that with passkeys too if you like, but it’s quite a lot of complexity. Instead, I think you can have just two buttons:
First, a button to add a passkey that uses the
createOptions
object from above, but with
authenticatorAttachment
deleted in order to allow other
devices to be registered.
Second, a “reset passkeys” button (like a “reset password” button). It would prompt for a new passkey registration, delete all other passkeys, and invalidate all other active sessions for the user.
Test vectors
Connecting up to your language’s crypto libraries is one of the trickier parts of this. To help, here are some test vectors to give you a ground truth to check against, in the format of Python 3 code that checks an assertion signature.
import codecs
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import (
load_der_public_key)
# This is the public key in SPKI format, as obtained from the
# `getPublicKey` call at registration time.
= '''
public_key_spki_hex 3059301306072a8648ce3d020106082a8648ce3d03010703420004dfacc605c6
e1192f4ab89671edff7dff80c8d5e2d4d44fa284b8d1453fe34ccc5742e48286
d39ec681f46e3f38fe127ce27c30941252430bd373b0a12b3e94c8
'''
# This is the contents of the `clientDataJSON` field at assertion
# time. This is UTF-8 JSON that you also need to validate in several
# ways; see the main body of the text.
= '''
client_data_json_hex 7b2274797065223a22776562617574686e2e676574222c226368616c6c656e67
65223a22594934476c4170525f6653654b4d455a444e36326d74624a73345878
47316e6f757642445a483664436141222c226f726967696e223a226874747073
3a2f2f73656375726974796b6579732e696e666f222c2263726f73734f726967
696e223a66616c73657d
'''
# This is the `authenticatorData` field at assertion time. You also
# need to validate this in several ways; see the main body of the
# text.
= '''
authenticator_data_hex 26bd7278be463761f1faa1b10ab4c4f82670269c410c726a1fd6e05855e19b46
0100000cc7
'''
# This is the signature at assertion time.
= '''
signature_hex 3046022100af548d9095e22e104197f2810ee9563135316609bc810877d1685b
cff62dcd5b022100b31a97961a94b4983088386fd2b7edb09117f4546cf8a5c1
732420b2370384fd
'''
def from_hex(h):
return codecs.decode(h.replace('\n', ''), 'hex')
def sha256(m):
= hashes.Hash(hashes.SHA256())
digest
digest.update(m)return digest.finalize()
# The signed message is calculated from the authenticator data and
# clientDataJSON, but the latter is hashed first.
= (from_hex(authenticator_data_hex) +
signed_message
sha256(from_hex(client_data_json_hex)))
= load_der_public_key(from_hex(public_key_spki_hex))
public_key
public_key.verify(from_hex(signature_hex),
signed_message,
ec.ECDSA(hashes.SHA256()))# `verify` throws an exception if the signature isn't valid.
print('ok')
Where to ask questions
StackOverflow
is a reasonable place, with the passkey
tag.