The problem
The dashboard of Denia Print started with one shared username and password. That works for one person. With a team it falls apart: you cannot see who changed an order and you cannot take access away from one person.
I wanted every team member to have their own account, with roles, and no passwords at all. The dashboard runs on Cloudflare Pages Functions with Workers KV as storage, so there is no session server to lean on.
The approach
There are two ways to sign in at dashboard.deniaprint.es: a passkey (Touch ID, Face ID, Windows Hello or a phone) or a code sent by email that is valid for 10 minutes.
Passkeys. The server side uses @simplewebauthn/server. Credentials are discoverable and user verification is required, so the browser offers the right passkey without asking for an email address first. The user handle is a random value, not the email address. The browser library is stored in the repository and served from the dashboard’s own host, which means the Content Security Policy needs no extra source.
Sign-in challenges are stateless. The options I send to the browser carry an expiry time and a signature, valid for 5 minutes. Anyone can request a challenge without it costing a write to KV. Only after a valid passkey response do I write a short “used” marker, so a challenge works once. If that marker cannot be written, the sign-in is refused.
Codes by email. The code has 10 characters from an alphabet without lookalikes (no 0, O, 1 or I), about 50 bits. Upper or lower case, spaces and dashes do not matter when you type it in. The details that took the most thought:
- The response is always the same, also for an unknown address, so the form does not reveal who has an account. Storing and mailing happen after the response has gone out, and an unknown address costs no KV write.
- Every attempt is counted before the comparison. If the count cannot be written, there is no comparison. A code allows 5 attempts and works once.
- Each request is first counted in the memory of the isolate (per IP, per address, per code) and only then touches KV. A Cloudflare WAF rate rule on both login paths sits on top of that.
Sessions. A session is a signed cookie with the email address, a session id and an expiry of 30 days. The list of sessions per account lives in KV. Members see their own passkeys and sessions and can log out everywhere. An owner can end someone else’s sessions, remove passkeys and disable a member. A sign-in from a device the account has not used before sends that member an email.
Roles. There are three: owner, admin and staff. A role is a list of permission strings such as orders.write or costs.read. Endpoints ask for a permission, never for a role name, so a custom role later is one more list and no endpoint changes. An endpoint missing from the table requires the team permission, and a test checks that every admin endpoint is listed. Staff see the selling price of an order but no purchase prices or margins: the API answers 403 on those endpoints and strips the cost fields from every JSON response.
Invites. An invite is a link that works once and stays valid for 7 days. If the email cannot be sent, the dashboard shows the link so the owner can pass it on.
The trade-offs
- Cached accounts. To save KV reads, each isolate remembers an account and its sessions for 60 seconds. Disabling a member stops their sessions at once in the isolate that handled the request, and within 60 seconds everywhere else.
- Email as a second door. The code by email stays available next to passkeys, because people lose phones and switch laptops. The account is therefore as safe as the mailbox behind it.
- Stateless challenges are not free. They save a write for every visitor who opens the login page, yet they need the “used” marker to stay single use.
- A vendored browser library. No new CSP host, but updates are a manual step.
- Retiring the old password. The old form kept working until an owner had signed in once with a passkey or a code. After that the old route answers 410 and the form is gone. The last owner cannot be demoted or disabled, so the team cannot lock itself out.
The customer accounts on the shop use the same code flow with a shorter, 8-character code. The full case is on the Denia Print project page.