// feedback Sign in to leave feedback about this page. Sign In →
all topics

Security

Identity Management: Adding Passkeys

Passwordless sign-in with WebAuthn: registering a passkey, conditional-UI autofill for near-invisible logins, and a fallback plan for devices that don't support it.

TL;DR

Part 1 covered what passkeys are and why they resist phishing. Part 2 built a complete password-based system. This part adds WebAuthn on top of that exact system — same members table, same session code, same require_role() gate — without touching any of it. A member can end up with a password, one or more passkeys, or both; nothing here removes the password path.

What Changes in the Schema

A passkey is a credential that belongs to a member, not a replacement for the member itself — and unlike a password, one member can reasonably have several (a phone, a laptop, a security key), so it needs its own table rather than a column on members. The actual CREATE TABLE is Step 2 below; the shape is one row per registered device, storing a public key and a signature counter — never anything the device itself needs to keep secret.

Step-by-Step: Registering a Passkey

STEP 1 Install the library

lbuchs/webauthn is a small, dependency-light WebAuthn server library — no attestation-chain framework to configure, no bundled UI. That matches this site's existing composer footprint (see .env Files & PHP Dotenv), so it's the one used here. It has nothing to do with Part 4's API keys and bearer tokens — those are an unrelated mechanism that just happens to live later in the same series, and don't need a bigger library either.

composer require lbuchs/webauthn

If deploys ship files with something other than a plain composer install on the server — a robocopy/scp script that excludes vendor/ and composer.json/lock to keep server-only dependencies off the wire, for instance — run composer require directly on the server itself, the same way any other server-only dependency gets installed there. Shipping the app code without it produces a plain Class "lbuchs\WebAuthn\WebAuthn" not found fatal the first time any passkey endpoint runs.

STEP 2 Create the passkey_credentials table

●●● create-passkey-credentials.sql
CREATE TABLE passkey_credentials (
  passkey_id     INT NOT NULL AUTO_INCREMENT,
  member_id      INT NOT NULL,
  credential_id  VARCHAR(255) NOT NULL,   -- base64url, from the authenticator
  public_key     TEXT NOT NULL,
  sign_count     INT NOT NULL DEFAULT 0,
  device_label   VARCHAR(100),
  created_at     DATETIME NOT NULL,
  last_used_at   DATETIME,
  PRIMARY KEY (passkey_id),
  UNIQUE KEY (credential_id)
);

UNIQUE KEY (credential_id) isn't optional here — sign-in looks a credential up by this value alone, before it knows which member is asking. Part 2's schema fix added that same kind of constraint to members after the fact; this table gets it from the start.

STEP 3 Generate a registration challenge

This endpoint only ever runs for someone already signed in with a password — require_role('member') guards it like any other member page:

// autoload MUST come before session_start() — see the note below the
// next code block for why
require_once __DIR__ . '/vendor/autoload.php';
session_start([/* httponly, secure, samesite */]);
require_once __DIR__ . '/includes/require-role.php';
require_role('member');

$rpId     = 'yoursitename.com';   // must exactly match the domain — no scheme, no port
// the 4th arg is base64url encoding — without it, challenges go to the
// browser in a "=?BINARY?B?...?=" format the WebAuthn JS API can't decode
$webAuthn = new lbuchs\WebAuthn\WebAuthn('Your Site Name', $rpId, null, true);

// the browser treats this as an opaque handle, not a display value —
// packing member_id into raw bytes is enough
$userId = hex2bin(str_pad(dechex($_SESSION['member_id']), 32, '0', STR_PAD_LEFT));

$createArgs = $webAuthn->getCreateArgs(
    $userId,
    $_SESSION['email'],
    $_SESSION['email'],
    60 * 4,   // seconds before the challenge expires
    true      // requireResidentKey
);

$_SESSION['webauthn_challenge'] = $webAuthn->getChallenge();

header('Content-Type: application/json');
echo json_encode($createArgs);

requireResidentKey = true is the one flag that matters most in this whole article: it tells the authenticator to store the credential as a discoverable one the browser can offer on its own later, rather than a bare second factor that always needs a username typed first before it knows which credential to check. Without it, the conditional-UI sign-in in the next section doesn't work.

STEP 4 Verify the registration response and store the credential

require_once __DIR__ . '/vendor/autoload.php';
session_start([/* httponly, secure, samesite */]);
require_once __DIR__ . '/includes/require-role.php';
require_role('member');

$body = json_decode(file_get_contents('php://input'), true);

// CSRF check — same pattern as Part 2, just read from the JSON body instead of $_POST
if (!hash_equals($_SESSION['csrf_token'] ?? '', $body['csrf_token'] ?? '')) {
    http_response_code(403);
    exit;
}

$rpId      = 'yoursitename.com';
$webAuthn  = new lbuchs\WebAuthn\WebAuthn('Your Site Name', $rpId, null, true);
$challenge = $_SESSION['webauthn_challenge'] ?? null;
unset($_SESSION['webauthn_challenge']);

try {
    // the browser sends base64URL (- and _, no padding) — plain base64_decode()
    // silently mangles those characters instead of erroring, corrupting the data
    $result = $webAuthn->processCreate(
        \lbuchs\WebAuthn\Binary\ByteBuffer::fromBase64Url($body['clientDataJSON'])->getBinaryString(),
        \lbuchs\WebAuthn\Binary\ByteBuffer::fromBase64Url($body['attestationObject'])->getBinaryString(),
        $challenge,
        true,   // requireUserVerification
        true,   // requireUserPresent
        false   // failIfRootMismatch
    );
} catch (Throwable $e) {
    http_response_code(400);
    exit('Registration failed');
}

$hDB->insert_prepared(
    'INSERT INTO passkey_credentials
        (member_id, credential_id, public_key, sign_count, device_label, created_at)
     VALUES (?, ?, ?, ?, ?, NOW())',
    'issis',
    $_SESSION['member_id'],
    // store base64URL, matching what sign-in will send back for the lookup —
    // base64_encode() here and a base64URL string there would rarely match
    (new \lbuchs\WebAuthn\Binary\ByteBuffer($result->credentialId))->jsonSerialize(),
    $result->credentialPublicKey,
    $result->signatureCounter ?? 0,
    $body['label'] ?? 'Unnamed device'
);

Two easy-to-miss gotchas live in that snippet. First, $_SESSION['webauthn_challenge'] holds the library's ByteBuffer object directly, not a manually-encoded string. PHP can serialize and unserialize arbitrary objects in the session just fine — but only if the class is already autoloadable at the exact moment session_start() decodes the session data. That's why require_once .../vendor/autoload.php comes before session_start() in every endpoint on this page, not after. Get the order backwards and the class isn't known yet when the challenge is unserialized, so PHP quietly substitutes a useless __PHP_Incomplete_Class stub instead of a real ByteBuffer — which then fails deep inside processCreate() with a confusing Object of class __PHP_Incomplete_Class could not be converted to string error, not anything that looks like a session or ordering problem.

Second, everything the browser sends back — clientDataJSON, attestationObject, the stored credentialId — is base64URL, not standard base64: - and _ instead of + and /, and no padding. PHP's base64_decode() doesn't understand base64URL and won't error on it either; it just silently produces corrupted binary, so registration fails with a generic error and nothing in the log points at the real cause. Decode with the library's own ByteBuffer::fromBase64Url() instead, and encode the stored credential ID the same way (jsonSerialize() on a ByteBuffer) so it matches the base64URL string sign-in will look it up by later.

STEP 5 Wire the browser side

WebAuthn's browser API works in raw ArrayBuffers; everything crossing the network here is base64url text, so a small pair of conversion helpers does the translation both ways:

●●● js/passkeys.js
function b64urlToBuf(b64url) {
  const raw = atob(b64url.replace(/-/g, '+').replace(/_/g, '/'));
  const buf = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i);
  return buf.buffer;
}

function bufToB64url(buf) {
  const bytes = new Uint8Array(buf);
  let str = '';
  for (const b of bytes) str += String.fromCharCode(b);
  return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

async function registerPasskey(csrfToken) {
  const createArgs = await fetch('/passkey-register-begin.php').then(r => r.json());

  createArgs.publicKey.challenge = b64urlToBuf(createArgs.publicKey.challenge);
  createArgs.publicKey.user.id   = b64urlToBuf(createArgs.publicKey.user.id);

  const credential = await navigator.credentials.create(createArgs);

  await fetch('/passkey-register-finish.php', {
    method: 'POST',
    body: JSON.stringify({
      clientDataJSON:    bufToB64url(credential.response.clientDataJSON),
      attestationObject: bufToB64url(credential.response.attestationObject),
      csrf_token:        csrfToken,
    }),
  });
}
Four-stage flow: Click Add Passkey (while already signed in with a password, require_role('member')) leads to Server getCreateArgs (mints a challenge, stores it in session, requireResidentKey true) leads to Browser Prompts (navigator.credentials.create, Face ID or PIN, keypair generated on-device) leads to Server processCreate (verifies and stores the public key and credential ID, private key never sent).
Only the public key and credential ID ever leave the device.

Step-by-Step: Signing In With a Passkey

STEP 1 Generate a sign-in challenge

Unlike registration, this endpoint is reachable by anyone — nobody's signed in yet, and it doesn't need to know who's asking:

// autoload before session_start() here too, for the same reason as registration
require_once __DIR__ . '/vendor/autoload.php';
session_start([/* httponly, secure, samesite */]);

$rpId     = 'yoursitename.com';
$webAuthn = new lbuchs\WebAuthn\WebAuthn('Your Site Name', $rpId, null, true);

$getArgs = $webAuthn->getGetArgs([]);   // no credential list — the browser finds its own

$_SESSION['webauthn_challenge'] = $webAuthn->getChallenge();

header('Content-Type: application/json');
echo json_encode($getArgs);

An empty credential list is what makes this a discoverable-credential (passkey) flow rather than a traditional WebAuthn second factor, which normally lists the specific credential IDs it's willing to accept for an already-known username. Here the browser, not the server, is the one that knows which passkey to offer.

STEP 2 Verify the assertion

require_once __DIR__ . '/vendor/autoload.php';
session_start([/* httponly, secure, samesite */]);

$body         = json_decode(file_get_contents('php://input'), true);
$credentialId = $body['credentialId'] ?? '';   // already base64URL from bufToB64url()

$q = 'SELECT member_id, public_key, sign_count
      FROM passkey_credentials WHERE credential_id = ?';
// ... prepare, bind, execute, fetch into $member_id, $public_key, $sign_count ...

if (!$found) {
    http_response_code(400);
    exit('Unknown passkey');
}

$rpId      = 'yoursitename.com';
$webAuthn  = new lbuchs\WebAuthn\WebAuthn('Your Site Name', $rpId, null, true);
$challenge = $_SESSION['webauthn_challenge'] ?? null;
unset($_SESSION['webauthn_challenge']);

try {
    // base64URL decode, same reasoning as registration above
    $webAuthn->processGet(
        \lbuchs\WebAuthn\Binary\ByteBuffer::fromBase64Url($body['clientDataJSON'])->getBinaryString(),
        \lbuchs\WebAuthn\Binary\ByteBuffer::fromBase64Url($body['authenticatorData'])->getBinaryString(),
        \lbuchs\WebAuthn\Binary\ByteBuffer::fromBase64Url($body['signature'])->getBinaryString(),
        $public_key,
        $challenge,
        $sign_count   // rejects outright if the reported count doesn't exceed this
    );
} catch (Throwable $e) {
    http_response_code(400);
    exit('Passkey sign-in failed');
}

STEP 3 Reuse Part 2's session code

This is the point of building passkeys as an addition rather than a parallel system: once processGet() succeeds, the rest is identical to a password login, including the blocked check:

// SELECT email, admin, blocked FROM members WHERE member_id = ?
// ... prepare, bind, execute, fetch into $email, $admin, $blocked ...

if ($blocked) {
    http_response_code(403);
    exit('Account blocked');
}

session_regenerate_id(true);
$_SESSION['signed_in']  = true;
$_SESSION['email']      = $email;
$_SESSION['member_id']  = $member_id;
$_SESSION['admin']      = $admin;
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

// $webAuthn->getSignatureCounter() — the new count, not the old $sign_count read
// from the DB above. Re-storing the old value would silently defeat clone
// detection on every future sign-in with this credential.
$newSignCount = $webAuthn->getSignatureCounter() ?? $sign_count;
// UPDATE passkey_credentials SET sign_count = $newSignCount, last_used_at = NOW() WHERE credential_id = ?

echo json_encode(['redirect' => '/topics-member.php']);

Nothing about CSRF, session fixation, or the blocked check needed to be re-derived here — a passkey sign-in produces the exact same session a password login does.

STEP 4 Wire conditional UI in the browser

On the sign-in page, the email input needs one attribute added, and a script that offers the passkey the moment that field is focused — not on a button click:

<input type="email" name="email" autocomplete="username webauthn">
●●● js/passkeys.js (continued)
async function tryConditionalPasskey() {
  if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;
  if (!(await PublicKeyCredential.isConditionalMediationAvailable())) return;

  const getArgs = await fetch('/passkey-signin-begin.php').then(r => r.json());
  getArgs.publicKey.challenge = b64urlToBuf(getArgs.publicKey.challenge);

  const credential = await navigator.credentials.get({
    publicKey: getArgs.publicKey,
    mediation: 'conditional',
  });

  const result = await fetch('/passkey-signin-finish.php', {
    method: 'POST',
    body: JSON.stringify({
      credentialId:      bufToB64url(credential.rawId),
      clientDataJSON:    bufToB64url(credential.response.clientDataJSON),
      authenticatorData: bufToB64url(credential.response.authenticatorData),
      signature:         bufToB64url(credential.response.signature),
    }),
  }).then(r => r.json());

  window.location = result.redirect;
}

document.addEventListener('DOMContentLoaded', tryConditionalPasskey);

STEP 5 Confirm it's working

Test plan:
  [ ] Sign in with a password, register a passkey, sign out
  [ ] Revisit the sign-in page and focus the email field
  [ ] Confirm the browser offers the passkey as an autofill suggestion
  [ ] Confirm a single tap (no typing) signs you in
  [ ] Confirm the password field still works for a browser/device that lacks passkey support
Five-stage flow: Email Field Focused (autocomplete username webauthn, no click required) leads to Server getGetArgs (mints a challenge, no credential list, browser finds its own key) leads to Autofill Offers It (one tap: Face ID, Touch ID, or PIN, nothing typed) leads to Server processGet (verifies signature, checks sign count, rejects a stale or cloned count) leads to Session (same code as password login, Part 2 unchanged).
The email field never has to be typed into — the browser fills the flow in on its own.

The Sign Counter and Clone Detection

Every time an authenticator produces a signature, it's supposed to increment a counter and report the new value. A received count that isn't strictly greater than what's stored is a signal that the same credential answered twice — a cloned authenticator, or a replayed assertion — and processGet()'s sixth argument above rejects on exactly that condition.

The nuance worth knowing before it surprises you in testing: cloud-synced passkeys — iCloud Keychain, Google Password Manager — commonly report a counter that stays at 0 on every single use, since the credential is shared across devices with no reliable central counter to increment. That's precisely the kind of passkey most users will actually have. A naive “reject if the count isn't strictly increasing” rule would need to treat a steady 0 as the normal case, not as suspicious — only a count that goes backward from a nonzero value is a reliable signal.

Where You Save It Matters

The browser's own "save a passkey" dialog isn't a formality — it decides which browsers and devices can use that credential afterward. Save it to a single browser's own password manager and it's typically scoped to that browser (or that browser's synced account) only. Save it to the operating system's platform authenticator instead — Windows Hello, macOS's Keychain — and it's stored at the OS level, so any browser installed on that machine can use it: a passkey saved to Windows Hello from Chrome shows up as an autofill option in Firefox or Edge on the same PC too. A hardware security key is the most portable option of all, tied to neither a browser nor a machine.

The server has no visibility into which of these a member picked — that choice happens entirely inside the browser's own dialog, before navigator.credentials.create() ever resolves. It's worth saying this up front on the registration page itself, so a member who wants the passkey to work from any browser on their PC knows to pick the platform option rather than "this browser only."

Fallback and Recovery

The password path built in Part 2 stays fully intact — not every browser or device supports passkeys yet, and this system never requires one. Lost every device with a passkey on it? The same password reset flow from Part 2 gets a member back in, after which they can register a new one.

Managing existing passkeys is a short list-and-delete page: show device_label and last_used_at per row for the signed-in member, with a button that runs DELETE FROM passkey_credentials WHERE passkey_id = ? AND member_id = ? — the member_id check matters, so one member can never revoke another's credential by guessing an ID.

Security Notes

Two things worth being deliberate about. First, the relying party ID passed to the library has to match the site's exact origin — a mismatch here is a common, subtle implementation bug, not a theoretical one. Second, and sharper: passkey registration itself needs CSRF protection and an active member session, exactly as built above. Skip either one and a hijacked session — the same session-invalidation gap Part 2 flagged and never fully closed — could be used to silently plant a persistent passkey on an account, giving an attacker a way back in even after the stolen session cookie itself expires.

What This Still Doesn't Cover

  • Attestation-chain verification — this build trusts the authenticator's self-reported key rather than verifying it against a manufacturer certificate chain, a deliberate scope line for most sites' threat models.
  • Admin tooling — no page for an admin to view or revoke a member's passkeys, mirroring the same gap already present for password accounts.

Part 4 moves to a different problem entirely: authorizing machine clients instead of browsers.

Checklist

Registering:
  [ ] passkey_credentials table, UNIQUE KEY on credential_id
  [ ] require_role('member') — registration only runs for an already-signed-in member
  [ ] requireResidentKey = true, or conditional UI won't work later
  [ ] CSRF token checked on the finish-registration endpoint
  [ ] Registration page notes that the OS platform option, not a single browser's manager, makes it work everywhere on that device
  [ ] vendor/autoload.php required BEFORE session_start(), on every endpoint that reads or writes webauthn_challenge
  [ ] composer require lbuchs/webauthn run directly on the server if your deploy script doesn't sync vendor/

Signing in:
  [ ] Empty credential list in getGetArgs() — lets the browser find its own key
  [ ] autocomplete="username webauthn" on the email field
  [ ] Sign counter checked on every assertion; 0-on-every-use is normal for synced passkeys
  [ ] Exact same session-establishment code and blocked check as Part 2's password login
  [ ] clientDataJSON/attestationObject/authenticatorData/signature decoded as base64URL (ByteBuffer::fromBase64Url), not base64_decode()
  [ ] Stored credential_id encoded as base64URL too, matching what sign-in looks it up by

Fallback:
  [ ] Password sign-in still works, unconditionally
  [ ] Password reset is the recovery path for a lost passkey
  [ ] Revoking a passkey checks member_id, not just passkey_id

Known gaps, addressed on purpose:
  [ ] No attestation-chain verification, no admin-side passkey management
top