Passkeys in Delphi: Passwordless Login With WebAuthn

· Components
Passkeys in Delphi: Passwordless Login With WebAuthn

Every password your application accepts can be guessed, reused on another site, typed into a fake login page or leaked from a database backup. Passkeys remove all four problems at once. The user signs in with the fingerprint, face or PIN that already unlocks the phone or the laptop, and there is no secret on your server worth stealing.

In the overview of the new login components passkeys got five bullet points, and the SAML post covered sign-in for companies that run their own identity provider. This post is the passwordless side: how TsgcWSAPIServer_WebAuthn registers passkeys, signs users in without a user name, shows passkeys in the browser autofill list and keeps every credential in your own database.

What a Passkey Is

A passkey is 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. Your server stores only the public key, so a leaked table of passkeys lets nobody sign in. Every signature is bound to the domain of your site, so a look-alike phishing domain gets nothing it can use. That is what phishing-resistant means here.

In WebAuthn terms, a passkey is a discoverable credential, also called a resident key. The authenticator keeps the user handle next to the private key, which is why the user can sign in without typing a user name and why the browser can list the passkeys of your site on its own.

Ask for Discoverable Credentials

WebAuthn needs a secure context, so serve your pages over https, or from localhost while you develop, and set WebAuthnOptions.RelyingParty to the host name the browser shows. The residentKey that the registration options ask for comes from WebAuthnOptions.DefaultOptions.Registration.DiscoverableCredential:

A single registration request can still override the default with the field discoverable_credential, set to required, preferred or discouraged.

Sign In Without a User Name

Ask for the authentication options with an empty user name. The options then carry no allowCredentials list, the browser shows the passkeys it holds for your relying party, and the user picks one. There is nothing to type and nothing to mistype.

Because the server did not choose the credential this time, it checks more. The response must include the userHandle, the userHandle must belong to the credential that signed, and a credential your application does not know is rejected.

Passkeys in the Autofill List

Autofill, or conditional mediation, puts the passkeys of your site in the suggestion list of the user name field, next to the saved passwords. That is the gentle way to move users over: the login page keeps working for people with a password, and people with a passkey pick it from the list.

Autofill needs a browser with conditional mediation, which today means current Chrome, Edge and Safari. browserSupportsWebAuthnAutofill() returns False in the others, so keep a “Sign in with a passkey” button on the page as well.

Several Passkeys, One User Handle

Real users have more than one passkey: one on the laptop, one on the phone, maybe a security key for the day both are lost. Register each of them with the same user name. When that user name already has credentials the server knows, the new registration reuses their user handle (user.id), so every passkey of the account shares one user handle and all of them lead to the same user.

The server knows the credentials registered while it runs and the ones you add with AddCredential. When your passkeys live in a database, add them at startup so every account keeps a single user handle. When a user does type a user name, the authentication options list every passkey of that user in allowCredentials, and the authenticator uses whichever one it holds.

Keep the Passkeys in Your Own Database

The component does not persist credentials. Your passkeys belong next to your users table, and four events connect the two:

Saving a passkey and loading it back are two short handlers:

uses
  sgcWebAuthn_Classes;

// registration: store the whole record, keyed by its credential id
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;

// usernameless and autofill sign-in: the browser chose the passkey,
// find it by its credential id and hand it back to the server
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;

DBInsertPasskey and DBFindPasskey stand for your own data access code. The record you return must carry the requested CredentialId, and the same Username when the ceremony started with a user name, otherwise the sign-in fails. Keep the UserId in the stored record too, because the server compares it with the userHandle the authenticator sends. The events run in the server connection threads, so protect shared resources the way you protect them anywhere else in the server.

Synced or Device-Bound

The flags in the authenticator data tell you what kind of passkey you received, and the credential record keeps them as BackupEligible (the BE flag) and BackupState (the BS flag):

Your policy can treat them differently, for example accepting only device-bound security keys on administrator accounts. At every sign-in the server rejects a response with the BS flag set and the BE flag clear, 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, and OnWebAuthnAuthenticationSuccessful is the place to save it.

The Signature Counter and Cloned Authenticators

Some authenticators increase a signature counter at every sign-in. When the counter in the response or the stored SignCount is not zero, the counter in the response must be greater than the stored value. If it is not, the sign-in is rejected, because two authenticators answering with the same key is what a cloned authenticator looks like.

The check only works if you save the counter after every sign-in, and the stored value only ever moves forward. Synced passkeys usually report 0 every time, which switches the check off for them. If you have authenticators that do not increase the counter reliably, set WebAuthnOptions.AllowSignCountLessOrEqualStoredValue to True to accept them. The stored value is still never lowered.

Try the Demo

The demo Demos\26.Authentication\01.Passkeys is a complete relying party: a TsgcWebSocketHTTPServer with a TsgcWSAPIServer_WebAuthn attached, serving a small login page on https://localhost:5443. It stores every passkey in a passkeys.json file of its own, through the four events above.

  1. Build the demo and keep libcrypto-3.dll and libssl-3.dll next to the executable. They are in the demo folder.
  2. Keep host 127.0.0.1, port 5443 and relying party localhost, then click Start.
  3. Click Open Browser and accept the self-signed test certificate.
  4. Type a user name and click Register passkey. Register a second passkey for the same user name. The form lists every passkey with its type, synced or device-bound, and its signature counter.
  5. Click Sign in without user name and choose a passkey. The server finds it through OnWebAuthnAuthenticationGetCredential and the log shows the user.
  6. Reload the page and click the user name box. The passkeys appear in the autofill list. Pick one to sign in.
  7. Restart the application. The passkeys load again from passkeys.json, which shows that the storage belongs to your application and not to the component.

Documentation

Where to Get It

TsgcWSAPIServer_WebAuthn is included in the Enterprise and All-Access editions of sgcWebSockets, for Delphi and C++ Builder, and the same component is part of sgcWebSockets .NET. If you only need authentication, the sgcAuth pack has it with the other login components. You find it on the SGC Auth palette, and nothing changes in an existing application until you drop it on a form.

Watch It

There is a short video, “Passkeys in Delphi: passwordless login with WebAuthn”, on the eSeGeCe channel. It shows the code in the IDE, what the backup flags and the signature counter tell you, and the demo running on https://localhost: a user registers two passkeys, then signs in without typing a user name.

Questions, feedback or help adding passkeys to your login page? Get in touch. You will get a reply from the people who wrote the code.