Passkey login flow
assay-engine v0.2.0's auth-passkey module wraps
webauthn-rs and exposes WebAuthn register + authenticate ceremonies behind four
HTTP endpoints (and four Lua wrappers).
Configure the relying party
Passkeys need a stable origin. The engine derives it from server.public_url;
override the relying-party id and label in engine.toml if you want them to
differ from the default (the host of the public URL):
# engine.toml
[server]
public_url = "https://auth.example.com"
[auth.passkey]
rp_id = "auth.example.com" # defaults to host of public_url
rp_name = "Acme Identity" # defaults to "Assay"
HTTP surface
| Method | Path | Purpose |
|---|---|---|
| POST | /auth/passkey/register/start | Begin registration ceremony — returns publicKey options + opaque state |
| POST | /auth/passkey/register/finish | Verify the browser's navigator.credentials.create() response, persist the credential |
| POST | /auth/passkey/auth/start | Begin authentication ceremony — returns challenge + allowed credentials |
| POST | /auth/passkey/auth/finish | Verify the browser's navigator.credentials.get() response, mint a session |
Lua API — assay.auth.passkey
The assay.auth stdlib module exposes the same ceremonies. The
state blob round-trips opaquely between start and finish — keep it in the
session/local storage on the browser side and pass it back unchanged.
local auth = require("assay.auth")
local c = auth.client({ engine_url = "http://localhost:3000" })
-- 1. Registration
local opts, state = c.passkey:start_register(user_id, user_name, display_name)
-- ship `opts` to the browser → navigator.credentials.create(opts)
-- collect `reg_response` from the browser
local cred = c.passkey:finish_register(reg_response, state)
-- credential is now stored in auth.passkeys for `user_id`
-- 2. Authentication
local challenge, state2 = c.passkey:start_auth(user_id)
-- ship `challenge` to the browser → navigator.credentials.get(challenge)
-- collect `auth_response` from the browser
local sess = c.passkey:finish_auth(auth_response, state2)
-- sess = { session_id, csrf_token, user }
Browser side (JS sketch)
// Registration
const start = await fetch("/auth/passkey/register/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id, user_name, display_name }),
}).then(r => r.json());
const cred = await navigator.credentials.create({ publicKey: start.options });
await fetch("/auth/passkey/register/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reg_response: cred, state: start.state }),
});
// Authentication mirrors the same shape with auth/start + auth/finish,
// using navigator.credentials.get(...).
Storage
Each credential lands in auth.passkeys:
credential_id BLOB / BYTEA PRIMARY KEY
user_id TEXT REFERENCES auth.users(id) ON DELETE CASCADE
public_key BLOB / BYTEA
sign_count INTEGER -- monotonic, replay-protection
transports TEXT -- JSON array (usb, nfc, ble, internal, hybrid)
created_at REAL / DOUBLE PRECISION
The dashboard's Users pane shows every credential per user, with the ability to remove a single key (e.g. when a device is lost). Removing a user cascades to their passkeys via the foreign-key.
See Auth & IdP overview for the rest of the surface, and Compare vs Ory for how this stacks up against Kratos's WebAuthn flow.