A passkey-only admin for my site.
The private studio behind this site: passkeys only, a one-time enrollment code from the CLI, hashed session tokens, an audit log and cookie-free analytics.
This site has a private admin I call the studio. It is where I edit posts and projects, rebuild the CV PDF, read messages from the contact form and look at visitor stats. It is a Next.js static export served by Nginx, talking to a small Go API with a SQLite database. Only one person ever signs in to it: me.
That made one decision easy. There are no passwords. The studio accepts WebAuthn passkeys and nothing else, so there is nothing to phish, reuse or guess.
Passkeys, and how the first one gets in
Every passkey-only system has a bootstrapping problem: you need to be signed in to add a passkey, and you need a passkey to sign in. I solved it with a one-time enrollment code that can only be created from the server's command line.
The CLI generates a short random code from an alphabet without look-alike characters, stores only its hash with an expiry, and writes an audit entry. On the enrollment page I type the code, and the API lets me start registering a passkey. When registration finishes, the code is marked used in the same transaction that stores the credential:
res, err := tx.Exec(
`UPDATE enroll_codes SET used_at = ? WHERE code_hash = ? AND used_at = ''`,
now(), codeHash)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return errCodeAlreadyUsed // someone else finished with this code first
}res, err := tx.Exec(
`UPDATE enroll_codes SET used_at = ? WHERE code_hash = ? AND used_at = ''`,
now(), codeHash)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return errCodeAlreadyUsed // someone else finished with this code first
}The condition on used_at makes the update itself the check, so two registrations racing with the same code can't both succeed. Anyone who can create a code already has a shell on the server, so the code adds no new way in.
Registration asks for a discoverable credential (a resident key) and requires user verification, meaning the authenticator must check a fingerprint, face or PIN, not just a tap. Existing credentials are passed as exclusions, so the same authenticator can't be registered twice. Once signed in, I can add more passkeys without a code. The authorisation is checked again when the registration finishes, not only when it starts.
Sign-in uses the discoverable flow, so there is no username field. The browser offers my passkey, and the server checks that the returned user handle matches the single owner in constant time. A credential whose signature counter suggests it was cloned is refused. Failed sign-ins are counted per truncated IP, and too many in a short window means a temporary block.
The ceremony state between "begin" and "finish" lives in memory on the server, keyed by a random id in a short-lived cookie, and each ceremony can be used once.
Sessions that are useless if stolen from the database
A successful sign-in creates a random session token. The browser gets the token in a cookie. The database gets only its SHA-256 hash. If someone copied the database, they would have hashes that can't be turned back into working cookies.
The cookie uses the __Host- prefix, which forces it to be Secure, host-only and scoped to the whole path, with no Domain attribute. It is also HttpOnly and SameSite=Strict. Sessions have an idle timeout and an absolute lifetime, and both are checked on every request. The security page lists every passkey and every active session, and I can revoke either. Deleting a passkey also ends every session it created, and the API refuses to delete the last one, so I can't lock myself out.
Checking every write, and logging it
SameSite=Strict already stops most cross-site requests from carrying the cookie. Writes get a second check that doesn't depend on cookie behaviour:
func (s *Server) csrf(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if r.Header.Get("X-Studio") != "1" || r.Header.Get("Origin") != s.cfg.StudioOrigin {
writeErr(w, 403, "forbidden")
return
}
}
h(w, r)
}
}func (s *Server) csrf(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if r.Header.Get("X-Studio") != "1" || r.Header.Get("Origin") != s.cfg.StudioOrigin {
writeErr(w, 403, "forbidden")
return
}
}
h(w, r)
}
}Every non-GET request must carry a custom header and an exact Origin. A plain HTML form on another site can't set a custom header, and a cross-origin script that tries triggers a CORS preflight, which fails because the API sends no CORS headers at all. Every studio endpoint goes through this wrapper and then a session check.
Every change writes an audit row: who did it (the passkey's label, or "system" or "cli"), what action, which entity and id, the truncated IP, the user agent, and a small JSON detail. Logins, failed logins, new passkeys, revoked sessions, edited posts, rebuilt PDFs, all of it. The failed-login limit reads its counts from this same log, so there is one record of what happened, not two that can disagree.
Analytics without cookies
The studio also shows who visits the site, and I wanted that without a cookie banner and without third-party scripts. The public site sends small beacons to the same Go API. The session id lives in the browser's session storage and disappears when the tab closes. Nothing is set as a cookie.
IP addresses are never stored in full. Before anything is written, an IPv4 address is cut to its /24 network and an IPv6 address to its /48. That is enough to look up which network or company a visit came from, using a local IP-to-ASN table, and far less precise than the full address. Known bots are flagged and left out of the numbers. The site also reports Core Web Vitals from real visits to the same API, which is how the colophon page can show real-world speed instead of only lab scores.
The studio is never linked from the public site, and it sends a header asking search engines not to index it. That isn't security, and it isn't meant to be. The passkeys are the security. Keeping it out of search results just means fewer scanners knocking on the door.
Get new posts by email
One email when something new goes up. No tracking pixels, unsubscribe in one click.