Passkeys

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.

Availability

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.

Register discoverable credentials

The property WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential sets the residentKey requested in the registration options:

ValueresidentKeyMeaning
waundcPreferred (default)preferredThe authenticator creates a discoverable credential when it can.
waundcRequiredrequiredOnly discoverable credentials are accepted. Use it for passkeys and usernameless sign in.
waundcDiscourageddiscouragedThe 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;

Several passkeys per user

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.

Usernameless sign in

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);

Passkey autofill

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.

Store the passkeys in your own database

The component does not persist credentials. Four events connect it to your storage:

EventWhat to do
OnWebAuthnRegistrationSuccessfulSave aCredentialRecord, for example aCredentialRecord.AsJSON, together with its CredentialId and Username.
OnWebAuthnAuthenticationOptionsRequestWith a user name, add the passkeys of that user to CredentialRecords. Without a user name, add nothing.
OnWebAuthnAuthenticationGetCredentialFires 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.
OnWebAuthnAuthenticationSuccessfulSave 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.

Synced or device-bound

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):

BackupEligibleBackupStateMeaning
TrueTrueSynced passkey, backed up by the platform or the password manager and available on the other devices of the user.
TrueFalseMulti-device passkey that is not backed up yet.
FalseFalseDevice-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.

Signature counter and cloned authenticators

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).

Demo

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.