Dynamo as inbox, with a passkey
I built a single-page mail reader on top of the DynamoDB table my mail pipeline writes to. Three-pane layout (inbox list / messages / body), search, attachment links — all served from one Lambda. The interesting part is the auth: a bootstrap token cookie, and after that, passkeys.
The shape
- Lambda + API Gateway v2, custom domain on the personal AWS account
- POST /api/query is the only data endpoint; the action is in the body
(
list_inboxes,list_messages,get_message,search) - A single static HTML page that hits the API
- Auth lives in the same DynamoDB table as the mail, under a separate
AUTHpartition
Storing auth in the mail table
Same table, separate partition:
pk = AUTH
sk = CRED#<base64url(credential_id)> # registered passkeys
sk = CHAL#<id> # pending challenges, 5min TTL
sk = SESS#<id> # sessions, 30 day TTL
DynamoDB TTL on the ttl attribute auto-expires challenges and old
sessions. No cleanup job to run. The mail table is small enough that
adding a few hundred AUTH rows is a rounding error.
The bootstrap problem
WebAuthn requires a registered credential to log in. But you need some way to log in the first time to register that credential.
Solution: a one-shot bootstrap token in the URL.
https://inbox.example.com/?token=<random>
Visiting that URL once:
- Sets an HttpOnly cookie carrying the bootstrap token
- Server recognizes the token, marks the session "authed but no credential"
- UI shows the Register Passkey panel
- User enters a label ("iPhone"), hits Register, Touch/Face ID captures the credential
- Server stores the public key and sign count, drops the bootstrap pseudo-session, opens a real session
After that, plain https://inbox.example.com works without the
token. The bootstrap token is rotatable via a Lambda env var if it
ever leaks.
The library shopping
py_webauthn (webauthn on PyPI) handles all the heavy lifting —
challenge generation, attestation verification, sign count tracking.
Bundled as a Lambda layer along with cryptography, cbor2, and
pyOpenSSL.
RP ID is the bare hostname; origin is the full https:// URL. Get
either wrong and the browser silently refuses to register a
credential, which is fun to debug.
What stays in the cheap-and-good zone
list_inboxesdoes a Scan every page load. At current size it's fine. If the table ever grows past ~10k items I'll add a per- recipient counter row updated on write.- Search is Scan-based when there's no sender filter. Same deal — fine today, would need OpenSearch Serverless or similar later.
- No attachment downloader yet. The raw S3 key shows in the message detail; I just click through if I need to grab one. A signed-URL endpoint is a half-day of work whenever I get annoyed enough.
- No message delete from the UI on purpose. Dynamo is the archive. If I delete from Gmail (the mirror), Dynamo still has it.
What I'd do differently
The bootstrap-token-in-URL flow works but it's the only piece I'm not entirely happy with. If I were building this today I'd probably do a one-time magic link from a verified sender email instead — same "prove you can read inbox.example.com mail" property, no long-lived URL token to worry about. The current setup is fine for one user; it would need a rethink before being multi-user.