Skip to content

Token model

Every booking gets one opaque token. Each token maps to exactly one booking and is the credential carried in URLs and the session cookie.

Properties

  • 256 bits of entropy — 32 random bytes from crypto.getRandomValues, base64url-encoded (~43 chars, URL-safe, no padding)
  • One per booking (UNIQUE(booking_id) index in D1)
  • Long-lived — issued at workflow spawn, valid until 30 days post-departure, then garbage-collected
  • Rotatable — re-issuing replaces the row, old token stops resolving

D1 schema

CREATE TABLE guest_tokens (
token TEXT PRIMARY KEY,
booking_id INTEGER NOT NULL,
issued_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
last_seen_at TEXT
);
CREATE UNIQUE INDEX idx_guest_tokens_booking ON guest_tokens(booking_id);
CREATE INDEX idx_guest_tokens_expires ON guest_tokens(expires_at);

Migration: 0035_guest_tokens.sql.

Why raw, not hashed

teamsummit-cal hashes its magic-link tokens at rest. The portal does not, deliberately:

  1. The token already appears in URLs / email bodies / Mailgun logs / browser history — it’s a session credential, not a password
  2. We need reverse-lookup (booking_id → token) for email rendering (“inject the portal URL into the welcome message”). Hashing would require keeping a separate raw copy somewhere, defeating the point
  3. D1 is not externally accessible — only worker code reads it. If the D1 row leaks, the threat model has already failed

The migration’s header comment captures this rationale.

Helpers

In src/lib/guest-token.ts:

FunctionPurpose
generateGuestToken()256-bit base64url string
computeTokenExpiry(departure)EOD UTC + 30 days
issueOrGetGuestToken(env, bookingId, departure)Get-or-create; rotates if existing is expired. Safe to call on every workflow respawn.
resolveGuestToken(env, token)URL → bookingId; touches last_seen_at; null on missing or expired
getGuestTokenForBooking(env, bookingId)For email rendering; null if none active
sweepExpiredGuestTokens(env)Daily-sweep target for P12.M7

Lifecycle

  1. IssueBookingFlagWorkflow (or whichever phase first needs the URL) calls issueOrGetGuestToken at workflow spawn time. Idempotent — re-spawns return the same token unless it’s expired.
  2. Serve — every guest-facing email rendering helper fetches the token and builds https://my.stayonthesnow.com/p/{token} URLs.
  3. Resolve — portal request hits /p/{token}, lookup in D1, last_seen_at updated, booking fetched, page rendered.
  4. Rotate — re-issuing for the same booking_id replaces the row via ON CONFLICT(booking_id) DO UPDATE. Old token returns 404.
  5. Expireexpires_at < now causes resolveGuestToken to return null. Daily sweep (P12.M7) drops the row.

Threat model

RiskMitigation
Token in URL leaks to analytics / shared screenshot256-bit entropy makes brute-force infeasible; cookie scope is per-booking only
D1 row leak (catastrophic)Same as session-token compromise: rotate every booking’s token; emails go out with fresh URLs
Stolen device with cookieToken rotation invalidates the cookie. No multi-factor; portal doesn’t expose payment auth (Stripe handles that directly)
Email forwardingWhoever has the URL has access — by design (multi-guest parties share). Sensitive operations behind explicit confirmation (e.g., add-on capture).