Passkeys replace passwords with a key pair created by the authenticator of the user: Windows Hello, iCloud Keychain, Google Password Manager, a password manager or a FIDO2 security key. The private key never leaves the authenticator, the server stores only the public key, and the signature is bound to the domain of the site, so passkeys can not be phished, reused or leaked from the server database.
Technically a passkey is a discoverable WebAuthn credential (a resident key): the authenticator keeps the user handle next to the private key, so the user can sign in without typing a user name, and the browser can offer the passkeys of the site in the autofill list of the user name field. The TsgcWSAPIServer_WebAuthn server supports passkeys with the settings and events described below.
The WebAuthn server is included in sgcWebSockets Enterprise and in the sgcAuth pack. WebAuthn needs a secure context: serve the pages over https (or localhost) and set WebAuthnOptions.RelyingParty to the host name shown in the browser.
The property WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential sets the residentKey requested in the registration options:
| Value | residentKey | Meaning |
|---|---|---|
waundcPreferred (default) | preferred | The authenticator creates a discoverable credential when it can. |
waundcRequired | required | Only discoverable credentials are accepted. Use it for passkeys and usernameless sign in. |
waundcDiscouraged | discouraged | The authenticator should create a server-side credential, which needs the user name to sign in. |
A registration request can override the default with the field discoverable_credential ("required", "preferred" or "discouraged"). The setting is only applied when the request does not ask for a specific residentKey itself.
WebAuthn.WebAuthnOptions.RelyingParty := 'www.example.com';
// ... passkeys are discoverable credentials: require a resident key
WebAuthn.WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential :=
waundcRequired;
WebAuthn.WebAuthnOptions.DefaultOptions.Registration.UserVerification :=
waunuvPreferred;
WebAuthn.WebAuthnOptions.DefaultOptions.Authentication.UserVerification :=
waunuvPreferred;
A user usually has several passkeys, for example one on the computer and one on the phone. Register each of them with the same user name: when the user name already has credentials known by the server, the registration reuses their user handle (user.id), so every passkey of the account shares one user handle. The server knows the credentials registered while it runs and the ones added with AddCredential, so when your credentials live in a database, add them at startup to keep one user handle per account. With a user name, the authentication options contain every passkey of the user in allowCredentials.
Request the authentication options with an empty user name. The options then carry no allowCredentials, the browser shows the passkeys it has for the relying party and the user picks one. The server requires the userHandle of the response and checks that it belongs to the credential used, and a credential which is not known by the application is rejected.
// ... "Sign in with a passkey" button: the browser lists the passkeys of the site
const options = await post("/sgcWebAuthn/Authentication/Options", {username: ""});
const assertion = await SimpleWebAuthnBrowser.startAuthentication(options);
const result = await post("/sgcWebAuthn/Authentication/Verify", assertion);
With conditional mediation the passkeys appear in the autofill list of the user name field, next to the saved passwords. Add autocomplete="username webauthn" to the input, load /sgcWebAuthn.js (served by the component, endpoint EndpointsOptions.WebAuthn) and call startAuthentication(options, true) with usernameless options when the page loads. The promise resolves when the user picks a passkey.
<script src="/sgcWebAuthn.js"></script>
<input id="username" name="username" autocomplete="username webauthn">
<script>
async function post(url, body) {
const r = await fetch(url, {method: "POST",
headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)});
return r.json();
}
async function passkeyAutofill() {
if (!(await SimpleWebAuthnBrowser.browserSupportsWebAuthnAutofill())) return;
// ... no user name: the options carry no allowCredentials
const options = await post("/sgcWebAuthn/Authentication/Options", {username: ""});
// ... true: conditional mediation, the passkeys appear in the autofill list
const assertion = await SimpleWebAuthnBrowser.startAuthentication(options, true);
const result = await post("/sgcWebAuthn/Authentication/Verify", assertion);
if (result.verified) { location.href = "/"; }
}
passkeyAutofill();
</script>
Autofill needs a browser that supports conditional mediation (current Chrome, Edge and Safari). browserSupportsWebAuthnAutofill() returns False in the other browsers, keep a "Sign in with a passkey" button for them.
The component does not persist credentials. Four events connect it to your storage:
| Event | What to do |
|---|---|
| OnWebAuthnRegistrationSuccessful | Save aCredentialRecord, for example aCredentialRecord.AsJSON, together with its CredentialId and Username. |
| OnWebAuthnAuthenticationOptionsRequest | With a user name, add the passkeys of that user to CredentialRecords. Without a user name, add nothing. |
| OnWebAuthnAuthenticationGetCredential | Fires when the credential used by the browser is not in the credential list of the ceremony, which is the case of a usernameless or autofill sign in. Look the credential id up, fill aCredentialRecord and set Found to True. |
| OnWebAuthnAuthenticationSuccessful | Save the updated SignCount and BackupState of aAuthentication.Credential.CredentialRecord and create the session. |
// ... 1. registration: store the new passkey (the whole record as JSON)
procedure TForm1.WebAuthnWebAuthnRegistrationSuccessful(Sender: TObject;
const aRegistration: TsgcWebAuthn_Registration;
const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Accept: Boolean);
begin
DBInsertPasskey(aCredentialRecord.CredentialId, aCredentialRecord.Username,
aCredentialRecord.AsJSON);
end;
// ... 2. sign in with a user name: the passkeys of that user
procedure TForm1.WebAuthnWebAuthnAuthenticationOptionsRequest(Sender: TObject;
const aRequest: TsgcWebAuthn_AuthenticationOptions_Request;
var CredentialRecords: TsgcWebAuthn_CredentialRecords; var Accept: Boolean);
var
i: Integer;
oList: TStringList;
begin
if aRequest.Username = '' then
Exit; // ... usernameless or autofill: no allowCredentials
oList := TStringList.Create;
try
DBGetPasskeysOfUser(aRequest.Username, oList); // ... one JSON per line
for i := 0 to oList.Count - 1 do
CredentialRecords.AddCredentialRecordFromJSON(oList[i]);
Accept := oList.Count > 0;
finally
oList.Free;
end;
end;
// ... 3. usernameless and autofill: find the passkey by its credential id
procedure TForm1.WebAuthnWebAuthnAuthenticationGetCredential(Sender: TObject;
const aCredentialId: string;
const aCredentialRecord: TsgcWebAuthn_CredentialRecord; var Found: Boolean);
var
vJSON: string;
begin
Found := DBFindPasskey(aCredentialId, vJSON);
if Found then
aCredentialRecord.ReadJSON(vJSON);
end;
// ... 4. signed in: save the new signature counter and backup state
procedure TForm1.WebAuthnWebAuthnAuthenticationSuccessful(Sender: TObject;
const aRequest: TsgcWebAuthn_AuthenticationVerify_Request;
const aAuthentication: TsgcWebAuthn_Authentication; var Accept: Boolean);
var
oRecord: TsgcWebAuthn_CredentialRecord;
begin
oRecord := aAuthentication.Credential.CredentialRecord;
DBUpdatePasskey(oRecord.CredentialId, oRecord.AsJSON);
CreateUserSession(oRecord.Username);
end;
The record returned by OnWebAuthnAuthenticationGetCredential must have the requested CredentialId, and the same Username when the ceremony was started with a user name, otherwise the sign in fails. Keep the UserId (user handle) in the stored record, it is compared with the userHandle sent by the authenticator. The events run in the server connection threads, protect shared resources accordingly.
The authenticator data flags tell what kind of passkey the server received. They are stored in the credential record as BackupEligible (BE flag) and BackupState (BS flag):
| BackupEligible | BackupState | Meaning |
|---|---|---|
| True | True | Synced passkey, backed up by the platform or the password manager and available on the other devices of the user. |
| True | False | Multi-device passkey that is not backed up yet. |
| False | False | Device-bound passkey, for example a security key or a TPM key that never leaves the device. Recommend the user to register a second passkey. |
function GetPasskeyKind(aRecord: TsgcWebAuthn_CredentialRecord): string;
begin
if aRecord.BackupEligible and aRecord.BackupState then
Result := 'synced passkey'
else if aRecord.BackupEligible then
Result := 'multi-device passkey, not backed up yet'
else
Result := 'device-bound passkey';
end;
At every sign in the server rejects a response with the BS flag set and the BE flag not set, and a response whose BE flag differs from the stored BackupEligible, because the eligibility of a credential never changes. The new BS flag is copied to BackupState, save it in OnWebAuthnAuthenticationSuccessful.
Some authenticators increase a signature counter at every sign in. When the counter of the response or the stored SignCount is not zero, the counter of the response must be greater than the stored value, otherwise the sign in is rejected because the authenticator may have been cloned. The stored value only moves forward, and it must be saved after every sign in for the detection to work. Synced passkeys usually always report 0, which disables the check for them. Set WebAuthnOptions.AllowSignCountLessOrEqualStoredValue to True to accept a counter that does not increase (the stored value is still never lowered).
The demo Demos\26.Authentication\01.Passkeys registers several passkeys per user, signs in without a user name and with autofill, shows whether every passkey is synced or device-bound, and stores the credentials in a JSON file of the application through the events above. See also WebAuthn and the JavaScript client.