Passkeys in PHP and C#, step by step

A passkey sign in is two steps on the server. This post builds both in PHP and in C#, protects the add step, and tests the refusals that matter most.

On this page

What a passkey is

A passkey is a key pair. The private key stays with the authenticator, which can be a security key, a phone or a laptop. The site keeps only the public key. To sign in, the site sends a random challenge, and the authenticator signs it. The site checks the signature with the public key. No secret travels over the network, and the site stores nothing that could be used to sign in.

Passkeys come in two kinds. Some stay on one device. Others are synced: the platform the person uses backs them up and copies them to the person's other devices. A synced passkey is easier to live with, but it also means the account on that platform protects the private key. The standard tells the server which kind it has. Two flags in the authenticator data do this (backup eligible and backup state, section 6.1 (opens in a new tab)). The servers in this post treat both kinds the same. A site with a higher risk may want to look at those flags.

A passkey belongs to one site name, called the RP ID. The browser tells the authenticator which site is asking, and the signed data includes that name. A fake site on another address cannot get a valid signature for the real one. This makes phishing of the sign-in step much harder, because the person has nothing to type into the wrong page. It does not protect the rest of the system. An attacker can still go after the recovery path, an old session or the person's device, so those parts need the same care.

The standard is Web Authentication, or WebAuthn, from the W3C. I have built passkeys twice: in a PHP project and in a C# service. The two servers look different, but the steps are the same. This post shows both, with code that I ran. My checks cover the cases I thought of. They do not prove that every case is covered.

The two steps

Everything happens in two steps, and each step has a request and an answer. The spec calls these steps ceremonies.

  1. Adding a passkey. The server sends options with a new challenge. The browser calls navigator.credentials.create(). The authenticator makes a new key pair, and the browser sends back the public key inside an attestation object. The server checks it and stores the public key.
  2. Signing in. The server sends a new challenge. The browser calls navigator.credentials.get(). The authenticator signs the challenge, and the browser sends back the signature. The server finds the public key and checks the signature.

The spec lists the checks in two places: section 7.1, registering a new credential (opens in a new tab), and section 7.2, verifying an authentication assertion (opens in a new tab). Both libraries in this post do most of these checks. I still do a few myself, and I say which ones.

The server must be sure of these things:

  • The challenge is random, was made by this server a few minutes ago, was made for this purpose, and is used only once.
  • The origin in the client data is exactly the address of the site.
  • The hash of the site name (the RP ID) in the authenticator data is the one the server expects.
  • The person was present, and the authenticator says that it verified them with a fingerprint, a face or a PIN.
  • The signature is valid for the stored public key.
  • The user handle in the answer belongs to the passkey that signed.
  • The sign count is handled as a risk signal. A section below covers it.

Who is adding the passkey

A new passkey is a new way into an account, so the add step needs more care than the sign-in step. The samples follow three rules.

  1. The account comes from the session. The add endpoints do not take a user name. They ask the session which account is signed in, and they answer 401 when nobody is.
  2. The challenge belongs to that account. When the server makes the options, it stores the user handle with the challenge. When the answer arrives, it compares that handle with the account of the current session. Someone who signs in as another person cannot finish a registration that was started for the first account.
  3. The request must come from your own pages. The session cookie alone does not prove that, because another site can make the browser send it. Every POST must carry an Origin header that is exactly the address of the site.

A real site should also ask for a fresh sign in before it lets someone add or remove a passkey, and it should send an email when that happens. The checks below include a request without a session, a registration started by one account and finished by another, and a request from another origin.

The browser code

The browser code is the same for both servers. The server sends challenges and ids as base64url text, and the browser API wants bytes, so two small helpers convert them. The rest is two calls to the browser and two calls to the server. The browser sends the session cookie by itself, so the add request carries no user name.

passkey.js
// Browser side of the passkey flow. The same file works with the PHP and the C# server.
// The server sends base64url text. The browser API wants bytes. These two helpers convert.
const toBytes = (text) => {
  const b64 = text.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((text.length + 3) % 4);
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
};
const toText = (buffer) => {
  let binary = '';
  new Uint8Array(buffer).forEach((b) => { binary += String.fromCharCode(b); });
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};

const post = async (url, body) => {
  const response = await fetch(url, {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error('The server refused the request.');
  return response.json();
};

// Add a passkey for the person who is signed in. The server knows the account from the session,
// so the request carries no user name.
export async function addPasskey() {
  const options = await post('/passkeys/register/options', {});
  const publicKey = options.publicKey ?? options; // PHP wraps the options in publicKey, C# does not
  publicKey.challenge = toBytes(publicKey.challenge);
  publicKey.user.id = toBytes(publicKey.user.id);
  for (const c of publicKey.excludeCredentials ?? []) c.id = toBytes(c.id);

  const cred = await navigator.credentials.create({ publicKey });
  return post('/passkeys/register/verify', {
    id: cred.id,
    rawId: toText(cred.rawId),
    clientDataJSON: toText(cred.response.clientDataJSON),
    attestationObject: toText(cred.response.attestationObject),
  });
}

// Sign in with a passkey. The person does not type a name.
export async function signInWithPasskey() {
  const options = await post('/passkeys/login/options', {});
  const publicKey = options.publicKey ?? options;
  publicKey.challenge = toBytes(publicKey.challenge);
  for (const c of publicKey.allowCredentials ?? []) c.id = toBytes(c.id);

  const cred = await navigator.credentials.get({ publicKey });
  return post('/passkeys/login/verify', {
    id: cred.id,
    rawId: toText(cred.rawId),
    clientDataJSON: toText(cred.response.clientDataJSON),
    authenticatorData: toText(cred.response.authenticatorData),
    signature: toText(cred.response.signature),
    userHandle: cred.response.userHandle ? toText(cred.response.userHandle) : '',
  });
}

The PHP library wraps its options in a publicKey key, and the C# library does not. The line options.publicKey ?? options handles both.

I ran this file in Node against the C# server. Node has no passkeys, so I wrote a small software authenticator with WebCrypto in browser-check.mjs. It sends the session cookie and the origin header, adds a passkey and then signs in twice. Later I also ran the same flow in a browser, with Windows Hello on a PC and with the screen lock of an Android phone.

A software authenticator for tests

To run the servers on their own, I wrote a software authenticator for each language. It makes a P-256 key, builds the same bytes that a real device sends and signs them. It is about 40 lines. The server cannot tell the difference, so I can test the refusals without a phone.

A real authenticator does more. It keeps the private key in protected hardware or in the operating system, and it signs only after it verified the person. My software key has neither. The flag that says "user verified" is only a bit that the authenticator sets, and the server has to trust it. With the attestation format none, which is the only one I accept, the server also does not learn which device made the key. For most sites this is enough, and it avoids the work of checking certificate chains. If you must know which device you are talking to, you need attestation, which is a bigger topic.

PHP with lbuchs/WebAuthn

For PHP I use lbuchs/WebAuthn (opens in a new tab), version 2.2.0, installed with composer require lbuchs/webauthn:2.2.0. It parses the CBOR data and checks the signatures with OpenSSL. The class below has one method for each of the four requests. The two finish methods catch every Throwable and throw one plain error, so a broken body never becomes an error page.

server.php, PasskeyServer
final class PasskeyServer
{
    public function __construct(
        private MemoryStore $store,
        private string $rpId,
        private string $origin,
        private string $rpName,
    ) {
    }

    /**
     * One library object for each step. The object makes one challenge and keeps it,
     * so a long-lived object would hand out the same challenge again and again.
     */
    private function library(): WebAuthn
    {
        // 'none': we ask for no attestation. true: ids and keys travel as base64url.
        return new WebAuthn($this->rpName, $this->rpId, ['none'], true);
    }

    /**
     * Every request that changes something must come from our own pages. The session cookie
     * alone does not prove that: another site can make the browser send it.
     */
    public function assertSameOrigin(?string $originHeader): void
    {
        if ($originHeader === null || !hash_equals($this->origin, strtolower(rtrim($originHeader, '/')))) {
            throw new RuntimeException('The request did not come from this site.');
        }
    }

    /**
     * Step 1 of adding a passkey: the options for navigator.credentials.create().
     * $sessionUser is the account that is signed in. It comes from the session, never from the request.
     */
    public function registrationOptions(?string $sessionUser): array
    {
        $user = $this->signedIn($sessionUser);
        $handle = $this->store->handleFor($user);
        $exclude = [];
        foreach ($this->store->passkeys as $id => $passkey) {
            if ($passkey['handle'] === $handle) {
                $exclude[] = unb64u($id);
            }
        }
        // 120 seconds, a discoverable credential, user verification required.
        $webauthn = $this->library();
        $args = $webauthn->getCreateArgs($handle, $user, $user, 120, true, true, null, $exclude);
        $this->store->putChallenge($webauthn->getChallenge()->getBinaryString(), 'create', $handle);
        return json_decode(json_encode($args), true);
    }

    /** Step 2: check the answer of the browser and keep the public key. Returns the credential id. */
    public function finishRegistration(?string $sessionUser, array $body): string
    {
        $fail = new RuntimeException('The passkey could not be added.');
        $user = $this->signedIn($sessionUser);
        try {
            $clientJson = unb64u($this->field($body, 'clientDataJSON'));
            $attestation = unb64u($this->field($body, 'attestationObject'));
            $open = $this->store->takeChallenge($clientJson, 'create');
            // The challenge must have been made for the account that is signed in now.
            if ($open === null || !hash_equals($open['handle'], $this->store->handleFor($user)) || !$this->originIsOurs($clientJson)) {
                throw $fail;
            }
            $data = $this->library()->processCreate(
                $clientJson,
                $attestation,
                $open['challenge'],
                true,   // user verification required
                true,   // user presence required
                false,  // no attestation root to match
            );
            $id = b64u($data->credentialId);
            if (isset($this->store->passkeys[$id])) {
                throw $fail; // this credential id is already known
            }
            $this->store->passkeys[$id] = [
                'handle' => $open['handle'],
                'key' => $data->credentialPublicKey,   // a public key in PEM form, not secret
                'count' => (int) $data->signatureCounter,
            ];
            return $id;
        } catch (Throwable) {
            // Bad input of any kind ends here. It must not become an error page.
            throw $fail;
        }
    }
server.php, PasskeyServer, continued
    /** Step 1 of signing in: no list of credentials, so the browser offers the passkeys it has for the site. */
    public function loginOptions(): array
    {
        $webauthn = $this->library();
        $args = $webauthn->getGetArgs([], 120, true, true, true, true, true, true);
        $this->store->putChallenge($webauthn->getChallenge()->getBinaryString(), 'get', '');
        return json_decode(json_encode($args), true);
    }

    /** Step 2: check the signature. Returns the user name. */
    public function finishLogin(array $body): string
    {
        $fail = new RuntimeException('The passkey sign in did not work.');
        try {
            $clientJson = unb64u($this->field($body, 'clientDataJSON'));
            $open = $this->store->takeChallenge($clientJson, 'get');
            $passkey = $this->store->passkeys[$this->field($body, 'id')] ?? null;
            if ($open === null || $passkey === null || !$this->originIsOurs($clientJson)) {
                throw $fail;
            }
            // The user handle in the answer, when there is one, must belong to the passkey.
            $handle = unb64u((string) ($body['userHandle'] ?? ''));
            if ($handle !== '' && !hash_equals($passkey['handle'], $handle)) {
                throw $fail;
            }
            $webauthn = $this->library();
            $webauthn->processGet(
                $clientJson,
                unb64u($this->field($body, 'authenticatorData')),
                unb64u($this->field($body, 'signature')),
                $passkey['key'],
                $open['challenge'],
                $passkey['count'],  // see the note about the sign count in the post
                true,
                true,
            );
            $count = $webauthn->getSignatureCounter();
            $this->store->passkeys[$this->field($body, 'id')]['count'] = $count ?? $passkey['count'];
            return (string) $this->store->userOf($passkey['handle']);
        } catch (Throwable) {
            throw $fail;
        }
    }

    private function signedIn(?string $sessionUser): string
    {
        if ($sessionUser === null || $sessionUser === '') {
            throw new RuntimeException('Sign in first.');
        }
        return $sessionUser;
    }

    /** A field of the request body. Anything but a non-empty string is refused. */
    private function field(array $body, string $name): string
    {
        $value = $body[$name] ?? null;
        if (!is_string($value) || $value === '') {
            throw new RuntimeException('Missing field.');
        }
        return $value;
    }

    /** The library accepts any host that ends with the RP ID. We want the exact origin. */
    private function originIsOurs(string $clientJson): bool
    {
        $data = json_decode($clientJson, true);
        return is_array($data) && is_string($data['origin'] ?? null)
            && hash_equals($this->origin, strtolower(rtrim($data['origin'], '/')));
    }
}

I found two traps in this library.

The challenge lives as long as the object. The library makes one challenge when you ask for the options, and the same object keeps it. If you reuse one object, every request gets the same challenge. That is fine in classic PHP, where each request starts fresh. It breaks in a worker that stays alive. So the library() method makes a new object for each step. This small script shows the difference:

challenge-check.php
<?php
// The library makes one challenge for each object and keeps it.
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use lbuchs\WebAuthn\WebAuthn;

$same = new WebAuthn('Example site', 'example.com', ['none'], true);
$same->getGetArgs();
$first = $same->getChallenge()->getBinaryString();
$same->getGetArgs();
$second = $same->getChallenge()->getBinaryString();
echo 'one object, two calls:    ', $first === $second ? 'the same challenge' : 'two challenges', "\n";

$other = new WebAuthn('Example site', 'example.com', ['none'], true);
$other->getGetArgs();
echo 'a new object:             ', $first === $other->getChallenge()->getBinaryString() ? 'the same challenge' : 'a new challenge', "\n";
Output
one object, two calls:    the same challenge
a new object:             a new challenge

The origin check is loose. The library accepts an origin when its host ends with the RP ID. I asked it directly, with the RP ID example.com:

origin-check.php
<?php
// Asks the library alone which origins it accepts for the RP ID "example.com".
// _checkOrigin is private, so the check goes through reflection. This is for the test only.
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

$webauthn = new lbuchs\WebAuthn\WebAuthn('Example site', 'example.com', ['none'], true);
$check = new ReflectionMethod($webauthn, '_checkOrigin');

foreach (['https://example.com', 'https://evil.example.com', 'https://notexample.com', 'https://example.org'] as $origin) {
    echo str_pad($origin, 28), $check->invoke($webauthn, $origin) ? 'accepted' : 'refused', "\n";
}
Output
https://example.com         accepted
https://evil.example.com    accepted
https://notexample.com      accepted
https://example.org         refused

A subdomain is accepted, and so is notexample.com, which belongs to somebody else. The signature still has to be valid for a key that this site stored, so this is not an open door. Still, I do not want to depend on that. The method originIsOurs() compares the origin with the exact address of the site.

C# with Fido2NetLib

For C# I use the NuGet package Fido2 (opens in a new tab), version 4.3.0, which contains the Fido2NetLib namespace. The code below is an ASP.NET Core minimal API. The first part sets up the library and the clock.

server.cs, setup
const string RpId = "example.com";
const string Origin = "https://example.com";

var clock = new TestClock();
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
builder.Services.AddSingleton<TimeProvider>(clock);
builder.Services.AddSingleton<SessionStore>();
builder.Services.AddSingleton<PasskeyStore>();
builder.Services.AddSingleton<IFido2>(new Fido2(new Fido2Configuration
{
    RPID = RpId,
    RPName = "Example site",
    Origins = new HashSet<string> { Origin }, // exact origins, no wildcard
    Timeout = 120_000,
}));

Origins is a set of exact addresses, so the loose host match from the PHP library is not a problem here. The Timeout value only goes to the browser as a hint. It does not make the server forget a challenge, so the store keeps its own expiry time. The library keeps the options of each step as JSON. I store that JSON next to the challenge and pass it back when the answer arrives. The four endpoints follow.

server.cs, the four endpoints
// Every POST must come from our own pages. The session cookie alone does not prove that:
// another site can make the browser send it.
app.Use(async (http, next) =>
{
    if (HttpMethods.IsPost(http.Request.Method) && http.Request.Headers.Origin != Origin)
    {
        http.Response.StatusCode = StatusCodes.Status403Forbidden;
        return;
    }
    await next();
});

// Step 1 of adding a passkey: the options for navigator.credentials.create().
// The account is the one that is signed in. It comes from the session, never from the request.
app.MapPost("/passkeys/register/options", (HttpContext http, IFido2 fido2, SessionStore sessions, PasskeyStore store) =>
{
    if (sessions.UserOf(http) is not { } user)
        return Results.Unauthorized();

    byte[] handle = store.HandleFor(user);
    CredentialCreateOptions options = fido2.RequestNewCredential(new RequestNewCredentialParams
    {
        User = new Fido2User { Id = handle, Name = user, DisplayName = user },
        ExcludeCredentials = store.CredentialIdsOf(handle).Select(id => new PublicKeyCredentialDescriptor(id)).ToList(),
        AuthenticatorSelection = new AuthenticatorSelection
        {
            ResidentKey = ResidentKeyRequirement.Required,       // a discoverable credential
            UserVerification = UserVerificationRequirement.Required,
        },
        AttestationPreference = AttestationConveyancePreference.None,
    });
    store.PutChallenge(options.Challenge, "create", handle, options.ToJson());
    return Results.Content(options.ToJson(), "application/json");
});

// Step 2: check the answer of the browser and keep the public key.
app.MapPost("/passkeys/register/verify", async (AttestationBody body, HttpContext http, IFido2 fido2, SessionStore sessions, PasskeyStore store, CancellationToken ct) =>
{
    if (sessions.UserOf(http) is not { } user)
        return Results.Unauthorized();
    try
    {
        byte[] clientJson = Base64Url.DecodeFromChars(body.ClientDataJSON);
        OpenChallenge? open = store.TakeChallenge(clientJson, "create");
        // The challenge must have been made for the account that is signed in now.
        if (open is null || !open.UserHandle.SequenceEqual(store.HandleFor(user)))
            return Results.BadRequest(new { error = "The passkey could not be added." });

        var credential = await fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
        {
            AttestationResponse = new AuthenticatorAttestationRawResponse
            {
                Id = body.Id,
                RawId = Base64Url.DecodeFromChars(body.RawId),
                Type = PublicKeyCredentialType.PublicKey,
                Response = new AuthenticatorAttestationRawResponse.AttestationResponse
                {
                    ClientDataJson = clientJson,
                    AttestationObject = Base64Url.DecodeFromChars(body.AttestationObject),
                },
            },
            OriginalOptions = CredentialCreateOptions.FromJson(open.OptionsJson),
            IsCredentialIdUniqueToUserCallback = (args, _) => Task.FromResult(!store.Has(args.CredentialId)),
        }, ct);
        store.Add(credential.Id, new Passkey(open.UserHandle, credential.PublicKey, credential.SignCount));
        return Results.Ok(new { ok = true });
    }
    catch (Exception e) when (e is not OperationCanceledException)
    {
        // Bad input of any kind ends here. It must not become a 500.
        return Results.BadRequest(new { error = "The passkey could not be added." });
    }
});

// Step 1 of signing in: no list of credentials, so the browser offers the passkeys it has for the site.
app.MapPost("/passkeys/login/options", (IFido2 fido2, PasskeyStore store) =>
{
    AssertionOptions options = fido2.GetAssertionOptions(new GetAssertionOptionsParams
    {
        AllowedCredentials = [],
        UserVerification = UserVerificationRequirement.Required,
    });
    store.PutChallenge(options.Challenge, "get", [], options.ToJson());
    return Results.Content(options.ToJson(), "application/json");
});

// Step 2: check the signature.
app.MapPost("/passkeys/login/verify", async (AssertionBody body, IFido2 fido2, PasskeyStore store, CancellationToken ct) =>
{
    try
    {
        byte[] clientJson = Base64Url.DecodeFromChars(body.ClientDataJSON);
        OpenChallenge? open = store.TakeChallenge(clientJson, "get");
        Passkey? passkey = store.Find(Base64Url.DecodeFromChars(body.RawId));
        if (open is null || passkey is null)
            return Results.BadRequest(new { error = "The passkey sign in did not work." });

        var result = await fido2.MakeAssertionAsync(new MakeAssertionParams
        {
            AssertionResponse = new AuthenticatorAssertionRawResponse
            {
                Id = body.Id,
                RawId = Base64Url.DecodeFromChars(body.RawId),
                Type = PublicKeyCredentialType.PublicKey,
                Response = new AuthenticatorAssertionRawResponse.AssertionResponse
                {
                    ClientDataJson = clientJson,
                    AuthenticatorData = Base64Url.DecodeFromChars(body.AuthenticatorData),
                    Signature = Base64Url.DecodeFromChars(body.Signature),
                    UserHandle = string.IsNullOrEmpty(body.UserHandle) ? null : Base64Url.DecodeFromChars(body.UserHandle),
                },
            },
            OriginalOptions = AssertionOptions.FromJson(open.OptionsJson),
            StoredPublicKey = passkey.PublicKey,
            // The library refuses a count that does not grow, unless both counts are 0.
            // See the note about the sign count in the post.
            StoredSignatureCounter = passkey.SignCount,
            // The user handle in the answer must belong to this passkey.
            IsUserHandleOwnerOfCredentialIdCallback = (args, _) =>
                Task.FromResult(args.UserHandle is null || args.UserHandle.SequenceEqual(passkey.UserHandle)),
        }, ct);
        passkey.SignCount = result.SignCount;
        return Results.Ok(new { user = store.NameOf(passkey.UserHandle) });
    }
    catch (Exception e) when (e is not OperationCanceledException)
    {
        return Results.BadRequest(new { error = "The passkey sign in did not work." });
    }
});

A few details matter. ResidentKey = Required asks for a discoverable credential, a passkey that the authenticator can offer without being told the user name. That is why the sign-in endpoint can send an empty list of allowed credentials. UserVerification = Required makes the fingerprint, face or PIN a rule. The callback IsUserHandleOwnerOfCredentialIdCallback is where you check the user handle. The catch blocks turn any bad input into a 400 answer.

The store keeps each open challenge under its own value with an expiry time, and it removes the challenge the first time it is read. The clock is a TimeProvider, so the check can move time forward without waiting five minutes:

server.cs, PasskeyStore
/// <summary>Users, passkeys and open challenges in memory. A real site uses a database.</summary>
class PasskeyStore(TimeProvider time)
{
    readonly ConcurrentDictionary<string, byte[]> handles = new();
    readonly ConcurrentDictionary<string, Passkey> passkeys = new();
    readonly ConcurrentDictionary<string, OpenChallenge> challenges = new();

    public byte[] HandleFor(string user) => handles.GetOrAdd(user, _ => RandomNumberGenerator.GetBytes(32));
    public string? NameOf(byte[] handle) => handles.FirstOrDefault(x => x.Value.SequenceEqual(handle)).Key;

    public IEnumerable<byte[]> CredentialIdsOf(byte[] handle) =>
        passkeys.Where(x => x.Value.UserHandle.SequenceEqual(handle)).Select(x => Base64Url.DecodeFromChars(x.Key));

    public bool Has(byte[] id) => passkeys.ContainsKey(Base64Url.EncodeToString(id));
    public void Add(byte[] id, Passkey passkey) => passkeys[Base64Url.EncodeToString(id)] = passkey;
    public Passkey? Find(byte[] id) => passkeys.GetValueOrDefault(Base64Url.EncodeToString(id));

    /// <summary>Keeps a challenge for five minutes.</summary>
    public void PutChallenge(byte[] challenge, string purpose, byte[] userHandle, string optionsJson) =>
        challenges[Base64Url.EncodeToString(challenge)] =
            new OpenChallenge(purpose, userHandle, optionsJson, time.GetUtcNow().AddMinutes(5));

    /// <summary>Finds the challenge named in the client data and removes it, so it works once. An old one is refused.</summary>
    public OpenChallenge? TakeChallenge(byte[] clientDataJson, string purpose)
    {
        using var data = JsonDocument.Parse(clientDataJson);
        string key = data.RootElement.TryGetProperty("challenge", out var c) ? c.GetString() ?? "" : "";
        return challenges.TryRemove(key, out var open) && open.Purpose == purpose && open.ExpiresAt > time.GetUtcNow() ? open : null;
    }
}

In production, the store is a database table or a cache with an expiry time. Your site already has sessions, so the check uses only a small stand-in for them.

The sign count

An authenticator can keep a counter that grows with every signature. The server stores the last value it saw. Section 6.1.1 of the spec (opens in a new tab) says that if either number is not zero and the new one is not greater than the stored one, the key may have been cloned. It also says the authenticator may be faulty, or the requests may have arrived in a different order. It does not tell the site what to do. The site decides by its own risk. An authenticator without a counter keeps the value at zero, and then there is nothing to compare.

Both libraries use the same rule: they refuse an answer when the count does not grow, unless both counts are zero. My samples keep that rule, and the checks show both sides. The rule is a choice. It does not prove that a key was copied. A softer choice is to accept the sign in, write a warning to a log and ask for another factor. Check what your own passkeys report before you rely on the rule.

What the checks cover

Both programs run the same cases against the real code. The PHP one runs with php check.php. The C# one runs with dotnet run server.cs, which starts the web app on a free local port and calls it over HTTP. In each case I changed one thing at a time. The cases come in four groups: adding a passkey, signing in, a passkey that always reports a count of 0, and bad input. A line that says CRASH would show a server error or an unexpected exception, because a refusal must be a plain 4xx answer or one kind of error.

Output of check.php, PHP 8.3
-- adding a passkey
add a passkey (signed in as reader)                   accepted
add a passkey without a session                       refused
add a passkey with the options of reader, as mallory  refused
add the same credential twice                         refused
add a passkey with a login challenge                  refused
add a passkey after the challenge expired             refused
add a passkey, answer made for another site           refused
add a passkey, no user verification                   refused
a request from another origin                         refused
a request with no Origin header                       refused
a request from our own origin                         accepted
-- signing in
sign in                                               accepted (reader)
sign in again                                         accepted (reader)
sign in a third time                                  accepted (reader)
the same answer sent again                            refused
answer made for another site                          refused
answer made for a subdomain                           refused
answer made for notexample.com                        refused
signed data has another RP ID                         refused
signed by another key                                 refused
no user presence                                      refused
no user verification                                  refused
sign count does not grow                              refused
the challenge expired                                 refused
-- a passkey that always reports a count of 0
add the second passkey                                accepted
sign in with count 0                                  accepted (reader)
sign in with count 0 again                            accepted (reader)
-- bad input
sign in with an empty body                            refused
add a passkey with an empty body                      refused
sign in with fields that are not text                 refused
add a passkey with fields that are not text           refused
sign in with text that is not base64url               refused
add a passkey with text that is not base64url         refused
sign in with base64url that is not JSON               refused
add a passkey with base64url that is not JSON         refused
Output of server.cs, .NET 10
-- adding a passkey
add a passkey (signed in as reader)                   accepted
add a passkey without a session                       refused
add a passkey with the options of reader, as mallory  refused
add the same credential twice                         refused
add a passkey with a login challenge                  refused
add a passkey after the challenge expired             refused
add a passkey, answer made for another site           refused
add a passkey, no user verification                   refused
a request from another origin                         refused
a request with no Origin header                       refused
-- signing in
sign in                                               accepted (reader)
sign in again                                         accepted (reader)
sign in a third time                                  accepted
the same answer sent again                            refused
answer made for another site                          refused
answer made for a subdomain                           refused
answer made for notexample.com                        refused
signed data has another RP ID                         refused
signed by another key                                 refused
no user presence                                      refused
no user verification                                  refused
sign count does not grow                              refused
the challenge expired                                 refused
-- a passkey that always reports a count of 0
add the second passkey                                accepted
sign in with count 0                                  accepted (reader)
sign in with count 0 again                            accepted (reader)
-- bad input
sign in with an empty body                            refused
add a passkey with an empty body                      refused
sign in with fields that are not text                 refused
add a passkey with fields that are not text           refused
sign in with text that is not base64url               refused
add a passkey with text that is not base64url         refused
sign in with base64url that is not JSON               refused
add a passkey with base64url that is not JSON         refused

The PHP list has three lines for the origin header, because there the check is a method that the endpoint calls. In C# a middleware does it for every POST. The "fields that are not text" case is already refused by the JSON binding of ASP.NET before my code runs. The same case in PHP reaches my code.

The checks do not cover everything. They leave out attestation formats other than none, RS256 and EdDSA keys, two requests at the same moment, rate limits, a password reset path, and the odd things that real devices do. The bad input list has four shapes, and many more are possible. Treat the list as a start for your own tests.

Before you go live

  • Keep a way back. A person can lose a phone. Keep the password, or give recovery codes, before you let anyone drop the password. In my PHP project, the setting that turns the password off needs two passkeys first.
  • Choose the RP ID with care. It is the site name, and passkeys cannot move to another one. Use a domain you will keep.
  • Protect the add step. Ask for a fresh sign in before a person adds or removes a passkey, and send an email when it happens.
  • Make a new session after a passkey sign in, as you do after any other sign in.
  • Limit the attempts. Count starts and failures for each address, as you do for any other sign in.
  • Store little. The credential id, the public key, the sign count and a name are enough. The public key is not a secret, but it still belongs in your normal backups.
  • Test with real devices. My automatic checks use software keys. I also tried the samples with Windows Hello and with an Android phone, because devices can behave in their own ways, for example with the sign count.

If you want a second factor next to the password for now, my post TOTP in PHP without a library shows how the codes of an authenticator app work.

Read more

More posts