Read up on how to add Passkeys to any application without code modifications.
Since Google announced support for passkeys for all Gmail accounts – a clear first step in phasing out passwords – passkeys have been a hot topic. But what are passkeys exactly, how do they work and how are they going to change the security landscape? In this blog post, we review the current state of the technology from a security standpoint and we’ll discuss some critical aspects of passkey implementation.
You can also jump straight to an example implementation: GitHub and live.
Table of content
This is a rather long blogpost, we split it in separate sections to make it easier to consume it:
Passkeys 101
Feel free to skip this section if you are already familiar with passkeys!
In this section:
What are passkeys?
The big promise of passkeys is to get rid of passwords, of most phishing attacks and, in some cases, of multi factor authentication (MFA).
A passkey is a commercial term for discoverable FIDO/WebAuthn credentials that can be used for passwordless authentication. From a technical point of view, a passkey is a public and private key pair that can be used to authenticate a user to a relying party (i.e., a website or an app) without using passwords.
Websites can create or verify credentials through a web API called WebAuthn, implemented in all major browsers.
The keys are stored in an authenticator (this is an approximation, we’ll discuss more below), which could be anything from a physical security key to a smartphone’s secure enclave or even a virtual, software-based system that implements the Client to Authenticator Protocol 2 (CTAP2). CTAP2 is how an authenticator talks to a web browser to complete a WebAuthn credential creation or verification process.
In other words, the browser talks to the authenticator through the CTAP2 protocol to perform the key creation and verification ceremonies initiated by a website through the WebAuthn APIs.
Why now?
The idea of using public-key cryptography for user authentication and the WebAuthn standard have been around for a number of years. However, until recently, there was no established infrastructure to share key pairs across devices, making account recovery and cross-device authentication hard. Due to these reasons, until now, the adoption of WebAuthn has been fairly modest, with it primarily implemented as a second authentication factor, not the primary one.
Furthermore, the UX in most browsers was very counterintuitive until recently.
Apple and Google’s introduction of passkeys, coupled with their push to build ways to share credentials across devices, has significantly boosted interest in WebAuthn over the past 12 months.
For example, Apple reported that Kayak, Robinhood and Instacart are all experimenting with it, and the recent passkey support for Gmail is the first large-scale rollout of the technology and will undoubtedly lead to significant improvements of the user experience.
How are credentials shared and stored?
As mentioned above, passkeys are key pairs, and the private key is not supposed to be accessible to the relying party or the outside world. According to the WebAuthn standards, there are two types of credentials:
- Discoverable credentials: stored on the client device, meaning that the private key itself is stored in the persistent storage embedded in the authenticator
- Server-side credentials: The private key is encrypted with a second private key stored in the authenticator. The resulting blob is used as the credential ID for the key pair. There are multiple ways to achieve this; the most common one is called key wrapping. Note that while the relying party does have access to the private key, the relying party won’t be able to access it without the access to the authenticator, providing security guarantees similar to discoverable credentials
Passkeys add a twist to this concept as they are discoverable credentials that can be exported from the authenticator.
It is critical to notice that while passkeys will reduce adoption friction for WebAuthn, the security guarantees of passkeys are lower than regular, non-exportable, WebAuthn credentials and they are not standardized. In fact, the WebAuthn standard says:
“This specification defines no protocol for backing up credential private keys, or for sharing them between authenticators. In general, it is expected that a credential private key never leaves the authenticator that created it. Losing an authenticator therefore, in general, means losing all credentials bound to the lost authenticator, which could lock the user out of an account if the user has only one credential registered with the Relying Party. Instead of backing up or sharing private keys, the Web Authentication API allows registering multiple credentials for the same user.”
Since there are no technical standards for passkeys, different providers synchronize credentials in various ways, and these details are often not disclosed publicly.
In a previous blog post, we analyzed how Apple synchronizes passkeys.
As for Google, they use the Google Password/Passkey Manager, the security of which is beyond the scope of this blogpost, but you can read more here.
It is worth noting that inter-operability is a hot-topic and vendors are increasingly working on ways to share passkeys across different providers and devices, for example Apple allows exporting passkeys via AirDrop.
A practical consequence of this arrangement is that the security of passkeys and account recovery heavily depends on the security of the account linked to the syncing service (often your Apple or Google account). While at first this may seem a scary proposition for many, most account recovery flows today offer no better security by simply resorting to a reset link sent to an email address.
Security and Threat-modeling
In this section:
Phishing
One of the key selling points of passkeys and WebAuthn is that they are safer than passwords, providing a strong protection against phishing. In fact, all WebAuthn compliant browsers enforce a number of security checks.
First of all, WebAuthn doesn’t work without TLS. Furthermore, browsers enforce origin checks for credentials and most will prevent access to the platform authenticator unless the window is in focus or, in the case of Safari, the user triggers an action.
Thanks to origin checks, credentials are bound to an origin and cannot be used on a different domain. For this reason, most of the recent attacks involving domain squatting and phishing would fall flat because the malicious site wouldn’t be able to initiate the WebAuthn authentication process.
In other words, an attacker trying to compromise user credentials will need to either find a cross-site scripting (XSS) bug in the target website or a vulnerability in the browser, both of which are very high barriers to overcome. These are the only way in which they can bypass the WebAuthn checks, and even then a successful attacker wouldn’t have access to the private key itself, but only to a session token/cookie, which will expire once the browsing session is over.
In this blog post, we show the technical details of how Chrome enforces such checks.
In comparison to all other authentication methods — where an attacker only has to register a seemingly related domain and have a similar looking webpage to fool the victim through phishing — it is clear that WebAuthn is a much safer authentication method.
Stolen Credentials
Server-side
Another benefit of passkeys is that even if the credential database of a relying party is compromised and credentials are leaked, an attacker won’t be able to exploit them because no clear-text private key is ever shared with the relying party. This also reduces the risk of dictionary attacks against reused credentials.
Client-side
However, the private keys are now stored on the user device and their security and threat model is not always straightforward to assess. Note that even for server-side credentials, the key used to derive the private keys is stored on the authenticator, and so the threat model is roughly the same.
As mentioned in the introduction, an authentication ceremony involves a website using the WebAuthn APIs to talk to the client/browser, which will interact with an authenticator via the CTAP2 protocol.
However, no storage security guarantees are enforced on the authenticator. In particular, the location where keys are stored is highly dependent on the user device, operating system and whether hardware security keys are used.
We ranked the different solutions currently available from strongest to weakest security guarantees:
- Hardware security keys (Yubikeys or similar): these are dedicated devices with hardware security modules that only hold keys
- Modern iOS/Android devices: most modern high end mobile devices have a Secure Element chip that offers similar security guarantees to a hardware security key, in that even if the device is compromised the keys won’t be leaked
- Modern desktop devices: most modern laptops have a security module. However, certain hardware vendors limit which browsers can make use of the secure element
- Legacy mobile devices and desktops: private keys are normally stored in the user filesystems and while they might be encrypted, compromising the user device will most definitely also compromise the credentials.
As a result, applications that require high security guarantees should assess the risk of storing credentials on unsafe, legacy devices. That said, those devices are also vulnerable to keyloggers which would compromise any password, so even an authenticator with lower security guarantees is in most cases better than passwords.
Adding credentials
Since stealing credentials is not a feasible attack pattern with passkeys, attackers might be more interested in finding logic vulnerabilities that allow them to add their own credentials to a user account. By adding credentials to an account, the attacker achieves the same effect (i.e., they are able to login and impersonate the user) as stealing credentials.
The WebAuthn standard doesn’t specify how a relying party should account for multiple credentials, hence it is left as a task for the developer. It does nevertheless encourage the registration of multiple credentials.
Different websites could opt for different approaches here, one is to only allow one credential per account (similar to a password) and outsource the credential recovery process to Google/Apple/Passkey synchronization provider. Another one is to have a step-up authentication mechanism that allows a user to add another credential to their account after a further identity verification on their identity.
The end of multi-factor authentication (MFA)?
The principle behind MFA is to increase the confidence in the user identity by using more than a single authentication factor. Generally, authentication factors can be one of three types: something you know, something you are or something you have.
Some authenticators such as modern mobile phones and security keys can enforce two or more factors at the same time when accessing passkeys. For instance, on iOS you are required to use FaceID (something you are) or enter your pin (something you know) in order to login with a passkey (something you have).
In certain jurisdictions, passkeys on modern authenticators could alone satisfy the EU Strong Customer Authentication standards (more on this here).
In general, non-roaming WebAuthn credentials stored on dedicated hardware security keys with biometrics/pin support should be safe as a replacement for MFA.
Whether passkeys could be a replacement for MFA is dependent on the trust the application has in the recovery process of the vendor synchronizing the credentials (in most cases, Google or Apple), because ultimately passkeys will be synchronized across multiple devices and could be recovered through the account recovery process of the vendors.
We think that, for the time being, MFA will likely still remain a key defense against attackers for high-security applications. However, for lower security applications, MFA could be entirely replaced by passkeys on modern devices within a few years.
Implementation considerations
Now that we’ve reviewed the UX and security implications of passkeys, let’s discuss key implementation details that a relying party should bear in mind when adopting passkeys.
In this section:
WebAuthn is a fairly complicated technology; we recommend not rolling out your own implementation.
Secure key generation
The process of creating a credential starts when the relying party sends a request with a number of parameters (full list here). At a minimum, the server must send a random challenge/nonce, like shows in this example:
user_cred = {
'user_name': '[email protected]',
'display_name': 'Mr Example’,
'user_id': os.urandom(12),
}
challenge = os.urandom(32)
…
return jsonify({
'credential': None,
'displayName': user_cred['display_name'],
'userName': user_cred['user_name'],
'userId': base64.b64encode(user_cred['user_id']).decode('ascii'),
'challenge': base64.b64encode(challenge).decode('ascii'),
'relyingPartyName': 'localhost'}
The web application will then call navigator.credentials.create
to generate a key pair.
The parameters of the call will look something like this:
credOptions = {
publicKey: {
// Relying party
rp: {
name: relyingPartyName // 'localhost'
},
// User parameters
user: {
id: userIdBuffer, // os.urandom(12)
name: userName, //[email protected]
displayName: params.displayName, //Mr Example
},
// This specifies the list of acceptable key types for the authenticator to generate.
// For most implementations, it’s either ECC (-7) or RSA (-256).
pubKeyCredParams: [{
type: 'public-key',
alg: -7, // ECC
}],
// Here we can decide whether to use the current device as the authenticator or whether
// we should allow the user to select the authenticator (eg: an external hardware security key)
// ‘platform’ forces the choice of the local device
authenticatorSelection: {
authenticatorAttachment: 'platform',
},
// This is to avoid replay attacks and needs to be random. It’s the nonce sent by the server
challenge: challengeBuffer,
// Ask the authenticator to generate an attestation statement to prove the device type used
// to generate this key. This doesn’t always work, apple devices will refuse to generate an
// attestation statement for instance.
attestation: 'direct',
}
Once the key pair has been generated, the public key is returned to the relying party together with a number of other fields. The relying party verifies the data and, if successful, stores the public key associated with that user. This is an example payload:
{
"attestation": "o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEgwRgIhAM19ZXaMJ703tCUuinx9Pqkh+hhKAh0N+nZYufV0SSX1AiEAxxOaRaVchuQDFn8FWnfrR9lbLPR7fHzTlJktbvra5x9oYXV0aERhdGFYpEmWDeWIDoxodDQXD2R2YFuP5K65ooYyx5lc87qDHZdjRQAAAACtzgACNbzGCmSLCyXx8FUDACCaZeUU5GyTWfAlwU+D8vq/UsVgjwH1RAt8G6ngG/pyF6UBAgMmIAEhWCBOVpnWqcLp0U6DyQe1roMkSBrTRcir+LcIP1Pa925OhSJYIE+275/X4cNt16Q4hOYj5HN/Qb0vKaEH4p7jtsHfxvJd",
"clientData": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiczBqMFVjTU4tVV8zcGdZLTFzeXNqVXhySEVxREJGWmQ2a3RVNnZoeVh0dyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZX0=",
"id": "mmXlFORsk1nwJcFPg_L6v1LFYI8B9UQLfBup4Bv6chc", //this value needs to be persisted in the server and associated with the user
"type": "public-key"
}
The clientData
object is a Base64-encode JSON:
{
"type": "webauthn.create",
"challenge": "s0j0UcMN-U_3pgY-1sysjUxrHEqDBFZd6ktU6vhyXtw",
"origin": "http://localhost:5000",
"crossOrigin": false
}
The attestation object is a Base64-encode CBOR map. The shape of the attestation object is:
{
"attStmt": { 'alg': -7,
'sig': b"0E\x02R\xa4\xfa\xf0\xb9@\xed\xc3\xeb\xf8\x0f"
b"\xd2?*p\xdd\x9b\x11W\xeb\xdd\xf0\x08\xcb4\x16"
b"\xa0\x88\xce\x02!\x00\xb4S\x07\xc8pJ\xb9=="
b"\x08\xe1Qf\x08\x959O\x8a/H7\x13\xec\x00"
b"[\x9e\xcf\x89\xca\xca\xfb"
},
"authData": b"I\x96\r\xe5\x88\x0e\x8cht4\x17\x0fdv`[\x8f\xe4\xae\xb9"
b"\xa2\x862\xc7\x99\\\xf3\xba\x83\x1d\x97cE\x00\x00\x00"
b"\x00\xad\xce\x00\x025\xbc\xc6\nd\x8b\x0b%\xf1\xf0U"
b"\x03\x00 \xe2\x98\xb2\xd8\xa5QK\x84\x02\x87\x83/Z"
b"\xaf\xa3\xf0E\xe1J\n\x99\xe7\xd9\x1cR\x9eE!\xeb\\{i\xa5"
b"\x01\x02\x03& \x01!X \xbel>^%\x90\x81U\xb7\xe1&\xa9\xda\x19r"
b"\x9c<4M\x9c\x1dkZ\xe8\x8d\xfb\xdd /\x02\x17\xe7X j&w\xc7"
b"\xf4\x18C\x8ec\x1e<\x95\xa7\x02\x1e\x18\xf9D\xb0["
b"\xde\x11\x912\xf4\xb8\xabX\xe0\xc4\x0eU",
"fmt": 'packed'
}
The decoded authData
(which is a variable-length byte array, full spec here) in the attestation
field gives us information about the credential just created and certain security flags we’ll discuss later:
{
"attestedCredData":
{ "aaguid": b"\xad\xce\x00\x025\xbc\xc6\nd\x8b\x0b%"
b"\xf1\xf0U",
"credentialId": b"\x19\x81\xe5\x989Dq]"
b"\x0b\xf5\xc6\xbc]Do\xbb\xd9-~\xac"
b"\x81\x8dgS\xea\x08S\x10?\x124\x95",
"credentialIdLength": 32,
"credentialPublicKey": { "alg": "ecc",
"eccurve": "P256",
"key": <cryptography.hazmat.backends.openssl.ec._EllipticCurvePublicKey object at 0x103f65b10>,
"kty": "ECP",
"x": b":\xe2\x0f}"
b"[\x8a\xd2$"
b"\xa2\xcc{."
b"l\xa4\xf7\xbf"
b"\xe8:\\\x18"
b"\x06\xcbC\x11"
b"K\xbeG\xe5"
b"\x0fL\nY",
"y": b"c\xa1\x96@"
b">e\xa1\xac"
b"\xcfB\xa4\x99"
b"\\\x01\x02\xc0"
b"\xe8\xce\x9e^"
b"#p\x07\xc3"
b"\x86\x07\x04s"
b"\x18P\x00\xa8"}},
"flags": {
"attestedCredDataIncluded": True,
"extensionDataIncluded": False,
"userPresent": True,
"userVerified": True
},
"flagsRaw": 69,
"rpIdHash": b"I\x96\r\xe5\x88\x0e\x8cht4\x17\x0fdv`[\x8f\xe4\xae\xb9"
b"\xa2\x862\xc7\x99\\\xf3\xba\x83\x1d\x97",
"signCount": 0
}
The relying party must carefully verify the following:
- The challenge in
clientData
corresponds to the one the relying party sent at the beginning of the key creation ceremony. - The blob contains the sha256 of the relying party id (rpId), in this example “localhost”. The relying party should verify that the hash matches the expected rpId.
- Verify that
attStmt
within theattestation
object is correct. In particular, each type of attestation statement has a different format and way to verify its validity. The attestation statement is key to assess the properties of the authenticator and its trustworthiness. Here’s more information on how to verify the statement based on their type.
Starting from iOS 16, Safari won’t generate attestation statements (attStmt
set to none
) for platform keys so a relying party won’t be able to verify the provenance of webauthn keys generated on the device.
Secure login
The high-level process to verify a login attempt is for the relying party to generate a random challenge and for the client to sign that challenge with a key such that the server can verify the signature on the challenge by possessing the public key associated with the keypair - this is called an assertion.
In other words, the relying party verifies an assertion made by the client by verifying the signature on the random nonce against a public key that was registered for that account.
In practice there are several steps that a relying party needs to perform in order to verify the assertion correctly.
As with key creation, the relying party sends the client options for the navigator.credentials.get
call. At a minimum these must contain a random nonce/challenge, the rpID and the list of allowed credentials:
return jsonify({
'challenge': base64.b64encode(last_challenge).decode('ascii'),
'rpId': 'localhost',
'userVerification': 'required',
'extensions': None,
'allowCredentials': [{
type: "public-key",
id: "AK-r2A2ophbN_fw_xsHxP3z-l-5SVcbZi5Ez9oLgWrY"
}],
'timeout': 60000})
The client passes the option to navigator.credentials.get
and the user is asked to authenticate. If successful, the return value is a PublicKeyCredential
object.
The object is sent to the server and looks as follows:
{
"id": "AK-r2A2ophbN_fw_xsHxP3z-l-5SVcbZi5Ez9oLgWrY",
"raw_id": "AK-r2A2ophbN_fw_xsHxP3z-l-5SVcbZi5Ez9oLgWrY",
"response": {
"client_data_json": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiWGE2RGdfRTB5TFVZelBja05URDBER2pEcHZtX2JKRDhmazZnbHZJUDc3YyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwMCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"authenticator_data": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAA",
"signature": "MEUCID2sx44Y2iuVdeBZV8BXxeuRGPzsOrbmS0mTCQ6j25SzAiEAwYem5DpkU_oHjVJopmWCSYDUhJpxGtwb7fjZPgZmaKA",
"user_handle": "1EUqUv7528w"
},
"authenticator_attachment": "platform",
"type": "public-key"
}
The four key steps for the verification procedure are:
- The challenge in
clientData
corresponds to the one the relying party sent - Verify that the credential id match one of the credentials stored for the user
- The payload contains the sha256 of the relying party id (rpId). The relying party should verify that the hash matches the expected rpId
- Verify that the signature on the blob is a valid signature of the concatenation of the
authData
and the sha256 of theclientDataJSON
fields. This signature must be made with the keypair the user is authenticating with
The full verification procedure is rather long and specified here.
Random nonces
For both authentication and credential creation, it is absolutely critical to make sure that nonces/challenges are truly random. Failure to do so would result in vulnerability to replay attacks.
It is also key for the relying party not to trust the client and its data. The relying party should store the nonce and verify it matches the one returned by the client.
Security flags
We’ve seen earlier how, as part of the key creation process, navigator.credentials.create
returns a set of flags.
The flags field is 1 byte in length and contains the following flags:
- Bit 0: User Present (UP) result.
- 1 means the user is present.
- 0 means the user is not present.
- Bit 1: Reserved for future use (RFU1).
- Bit 2: User Verified (UV) result.
- 1 means the user is verified.
- 0 means the user is not verified.
- Bits 3-5: Reserved for future use (RFU2).
- Bit 6: Attested credential data included (AT). Indicates whether the authenticator added attested credential data.
- Bit 7: Extension data included (ED). Indicates if the authenticator data has extensions.
In particular, Bit 0 indicates that the authenticator asked the user to perform a “user presence test”. The WebAuthn specification says:
A test of user presence is a simple form of authorization gesture and technical process where a user interacts with an authenticator by (typically) simply touching it (other modalities may also exist), yielding a Boolean result.
Note that this does not constitute user verification because a user presence test, by definition, is not capable of biometric recognition, nor does it involve the presentation of a shared secret such as a password or PIN.
Bit 2 means that the authenticator asked the user to perform a “user verification test”. The specification says:
User Verification The technical process by which an authenticator locally authorizes the invocation of the authenticatorMakeCredential and authenticatorGetAssertion operations. User verification MAY be instigated through various authorization gesture modalities; for example, through a touch plus pin code, password entry, or biometric recognition (e.g., presenting a fingerprint) ISOBiometricVocabulary. The intent is to distinguish individual users.
Note that user verification does not give the Relying Party a concrete identification of the user, but when 2 or more ceremonies with user verification have been done with that credential it expresses that it was the same user that performed all of them. The same user might not always be the same natural person, however, if multiple natural persons share access to the same authenticator.
These flags coupled with attestation data are key to establish the trustworthiness of a key. In fact a strong authenticator which has created a keypair with both user presence and user verification is a good candidate replacement for MFA, captchas and other forms of verification.
Unfortunately, as we have mentioned throughout the article Apple has disabled attestation data on their passkeys significantly reducing the value of these flags.
Discoverable credentials vs server-side credentials
You might have noticed in the code snippets above that our toy relying party sent credential IDs to the browser when the authentication ceremony started. Server-side credentials are not discoverable, in fact the specification specifies:
“This means that the Relying Party must manage the credential’s storage and discovery, as well as be able to first identify the user in order to discover the credential IDs to supply in the navigator.credentials.get() call”
Hence to work with server-side credentials, the relying party needs to send an array of allowed credentials IDs for the user to login with.
However discoverable credentials don’t require credential IDs to be sent by the relying party, simply sending the challenge and the rpId is sufficient.
return jsonify({
'challenge': base64.b64encode(last_challenge).decode('ascii'),
'rpId': site_config['relying_party_name'],
'userVerification': 'required',
'extensions': None,
'timeout': 60000})
Adding new credentials to an account
As discussed, adding new credentials to a user account is a non-standardized flow, which is ripe for abuse. Relying parties need to be careful as attackers are likely to target this flow to compromise user accounts.
Passkeys make client-side synchronization and backup of credentials easier, hence there would be fewer circumstances in which a user might need to add another passkey to their account or reset their passkey. However, relying parties should account for that scenario nonetheless and the WebAuthn standard specifically encourages the creation of multiple credentials per account.
There are generally three approaches to this:
- Only allow a single passkey per account (in this sense, a passkey is a 1:1 replacement for a password). This turns the problem of a lost passkey into an account reset issue.
- Allow multiple passkeys to an account after a Step-Up Authentication step of the same strength. In other words, the user needs to authenticate via another passkey before being able to add another credential.
- Allow multiple passkeys to an account after a Step-Up Authentication step of any strength. The upside here is the user experience, but the downside is that you reduce the security of the account to the security of the authentication factors used to add a new passkey.
Ultimately which path you pick is dependent on the sensitivity of your application.
Conclusion
Passkeys are a very exciting innovation in the authentication space. We are strong believers that identity is the last major area of security that hasn’t gone through a significant upgrade in the last 10 years and, as such, is one of the key weaknesses and sources of threats.
The weakened guarantees of passkeys, compared to standard WebAuthn credentials (in particular, the inability to perform attestation), reduces their usefulness when it comes to having stronger guarantees on the user verification process as well as the security of the private key. Ultimately we are still a long way from getting rid of account takeovers and passwords. However, passkeys are also the first step towards mass adoption of what we believe to be a safer identity posture for users, so we look forward to more websites adopting them.
We prepared a small playground to see passkeys in action through the SlashID SDK, check it out! GitHub and live.