@flarecart/passkeys
v1.0.0
Published
Passkey sign-in for an admin page — server flows over a store you choose (in-memory and Cloudflare D1 included), single-use challenges, provider names, session-cookie helpers, the browser calls, and a software passkey for tests.
Maintainers
Readme
@flarecart/passkeys
Passkey sign-in for an admin page: Face ID, Touch ID or a security key, and nothing else. Server flows over a store you choose (in-memory and Cloudflare D1 included), the browser calls, session-cookie helpers, and a software passkey for your tests.
Why
An admin page needs a way in that a stolen password, a leaked database or a phishing page can't open. Passkeys are that: a key pair the device keeps, bound to your site's address, used only after the device checks the person. But the WebAuthn ceremony has sharp edges every app relearns:
- A challenge must work once. Keep it in memory and two instances disagree; keep it forever and a captured response can be replayed. Here every challenge is stored, expires after five minutes, and is used up by one atomic delete.
- "Add a passkey" needs a way in for the first one. A one-time link, an invite, a setup token: whatever proves it, it must be used up only once the passkey verifies, or a failed attempt burns the link.
verifyRegistrationtakes anauthorisehook that runs exactly there. - The same device shouldn't be added twice, and nobody should type a username. Passkeys here are discoverable and listed in
excludeCredentials. - People want to know which device a passkey is. The AAGUID the authenticator reports names it: iCloud Keychain, Google Password Manager, 1Password, a YubiKey.
- The session after sign-in is where apps go wrong. The cookie helpers make the safe version the easy one: HttpOnly, Secure,
SameSite=Strict, path-scoped, and an origin check for every change. - Testing needs a device.
@flarecart/passkeys/testinghas a software passkey that makes real P-256 signatures and real CBOR attestations, so your tests run the genuine checks.
WebAuthn itself is @simplewebauthn/server, the one dependency: well audited, and not something to reimplement.
Features
- 🔑 Discoverable passkeys, user verification required: no usernames, no passwords, no codes.
- ♻️ Single-use, expiring challenges, kept in your store and claimed atomically.
- 🗄️ Bring your own storage: a five-method interface, with an in-memory store and a ready Cloudflare D1 store (plain prepared statements, schema included).
- 🚪 An
authorisehook between "verified" and "kept", for one-time enrolment links and invites. - 🏷️ Provider names from the AAGUID: iCloud Keychain, Google Password Manager, Windows Hello, 1Password, Bitwarden, YubiKey…
- 👥 One admin or many: passkeys can belong to a user id, or to the site.
- 🍪 Session-cookie helpers that make the safe defaults the only ones.
- 🌐 Browser helpers: native JSON parsing where the browser has it, a hand conversion where it doesn't, and refusals in plain words.
- 🧪 A software passkey for tests: real keys, real attestation, real signatures.
Install
npm install @flarecart/passkeysNode 22+, Cloudflare Workers, Deno and Bun: anything with Web Crypto.
Usage
A single-admin page on Cloudflare Workers with D1: the first passkey comes from a one-time link, and every sign-in after that is a passkey.
Migration (once):
import { d1Schema } from '@flarecart/passkeys';
console.log(d1Schema()); // paste into migrations/0001_passkeys.sqlServer:
import { clearCookie, createPasskeys, d1Store, isSameOrigin, PasskeyError, readCookie, sessionCookie } from '@flarecart/passkeys';
const COOKIE = '__Secure-shop_admin';
const PATH = '/admin';
const SESSION_MS = 12 * 3_600_000;
export default {
async fetch(request, env) {
const passkeys = createPasskeys({ store: d1Store(env.DB), rpName: 'Wave Shop admin' });
const url = new URL(request.url);
const json = (data, init) => Response.json(data, init);
// Every change must come from the admin page itself.
if (request.method !== 'GET' && !isSameOrigin(request, url.origin)) return json({ error: 'Wrong origin' }, { status: 403 });
try {
switch (`${request.method} ${url.pathname}`) {
// Add a passkey (the first with a one-time link; later ones signed in).
case 'POST /admin/passkeys/options':
return json(await passkeys.registrationOptions(url));
case 'POST /admin/passkeys': {
const { response, name, link } = await request.json();
const passkey = await passkeys.verifyRegistration(url, response, {
name,
authorise: async () => {
// Used up only now that the passkey has verified.
if (!(await useEnrolLink(env, link))) throw new Error('That link has expired.');
},
});
return json({ id: passkey.id }, { status: 201, headers: { 'Set-Cookie': sessionCookie(COOKIE, await startSession(env, passkey.id), { path: PATH, maxAgeMs: SESSION_MS }) } });
}
// Sign in.
case 'POST /admin/session/options':
return json(await passkeys.authenticationOptions(url));
case 'POST /admin/session': {
const { response } = await request.json();
const passkey = await passkeys.verifyAuthentication(url, response);
return json({ signedIn: true }, { headers: { 'Set-Cookie': sessionCookie(COOKIE, await startSession(env, passkey.id), { path: PATH, maxAgeMs: SESSION_MS }) } });
}
// Sign out.
case 'DELETE /admin/session':
await endSession(env, readCookie(request, COOKIE));
return json({ signedIn: false }, { headers: { 'Set-Cookie': clearCookie(COOKIE, { path: PATH }) } });
}
} catch (error) {
if (error instanceof PasskeyError) return json({ error: error.message, code: error.code }, { status: 401 });
throw error;
}
return json({ error: 'Not found' }, { status: 404 });
},
};startSession, endSession and useEnrolLink are yours: a random token in a table, its hash stored, an expiry. The cookie only carries the token.
Browser:
import { createPasskey, describeError, passkeysSupported, usePasskey } from '@flarecart/passkeys/browser';
/** POST JSON to the admin API. */
const post = async (path, body = {}) => {
const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) throw new Error((await res.json()).error);
return res.json();
};
async function signIn() {
try {
const options = await post('/admin/session/options');
await post('/admin/session', { response: await usePasskey(options) });
location.reload();
} catch (error) {
show(describeError(error)); // "Cancelled, or the passkey prompt timed out."
}
}
async function addPasskey(link) {
const options = await post('/admin/passkeys/options');
await post('/admin/passkeys', { response: await createPasskey(options), name: 'MacBook', link });
}
if (!passkeysSupported()) show('Open this page in a current browser.');Tests:
import { createPasskeys, memoryStore } from '@flarecart/passkeys';
import { SoftPasskey } from '@flarecart/passkeys/testing';
const passkeys = createPasskeys({ store: memoryStore(), rpName: 'Test' });
const device = await SoftPasskey.create({ aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd' });
const url = 'https://shop.example/admin/';
await passkeys.verifyRegistration(url, await device.register(await passkeys.registrationOptions(url), 'https://shop.example'));
const signedIn = await passkeys.verifyAuthentication(url, await device.assert(await passkeys.authenticationOptions(url), 'https://shop.example'));
signedIn.provider; // "iCloud Keychain"API
createPasskeys({ store, rpName, maxPasskeys?, challengeTtlMs?, now? })
| Method | What it does |
|---|---|
| registrationOptions(url, { userId?, userName?, userDisplayName? }) | Options for navigator.credentials.create(). Discoverable, user verification required, the existing passkeys excluded. The challenge is kept. |
| verifyRegistration(url, response, { name?, userId?, authorise? }) | Checks the new passkey (origin, RP ID, challenge, user verification), runs authorise, keeps it. Returns the Passkey. |
| authenticationOptions(url) | Options for navigator.credentials.get(). Names no passkeys: the browser offers the ones it has. |
| verifyAuthentication(url, response) | Checks a sign-in, updates the counter and lastUsedAt, returns the Passkey. |
| list(userId?), remove(id) | Straight to the store. |
url is the page's address (or the request's URL): its host is the RP ID and its origin is the only one accepted. Failures throw PasskeyError with a code (NOT_VERIFIED, UNKNOWN_PASSKEY, TOO_MANY_PASSKEYS, ALREADY_ADDED) and a message safe to show.
Stores
memoryStore(): for tests.d1Store(db, { passkeysTable?, challengesTable? })andd1Schema(options?): Cloudflare D1, or anything with D1'sprepare().bind().run()/first()/all().- Your own: implement
PasskeyStore(list,get,add,update,remove,keepChallenge,useChallenge, and optionallyprune).useChallengemust claim the challenge atomically.
Cookies
sessionCookie(name, value, { path?, maxAgeMs }), clearCookie(name, { path? }), readCookie(request, name), isSameOrigin(request, origin).
Other
providerOf(aaguid) and PROVIDERS, relyingParty(url), toBase64Url / fromBase64Url.
@flarecart/passkeys/browser
passkeysSupported(), createPasskey(options), usePasskey(options), describeError(thrown).
@flarecart/passkeys/testing
SoftPasskey.create({ aaguid?, backedUp? }), then .register(options, origin, { userVerified? }) and .assert(options, origin, { rpId?, userVerified? }).
Security notes
- A passkey works only on the host it was made on.
shop.exampleandshop.workers.devare different relying parties: add a passkey on each address you use. - Only public keys are stored. A leaked database holds nothing that signs in.
- Keep the session short and narrow. A cookie a browser holds should open what the admin page does and no more; leave anything more powerful (API keys, refunds, settings) to stronger proof.
- Check
Originon every change a cookie authorises (isSameOrigin), even withSameSite=Strict. - Recovery is your design decision. Losing every passkey shouldn't lock the owner out for good, but the way back in must be at least as hard to reach as the passkeys: a command run with the server's own credentials, not an email.
- Prune expired challenges from a cron (
store.prune(Date.now())) so the table stays small. - The software passkey is for tests only. It holds its private key in memory and signs whatever it's asked.
License
MIT
