Skip to main content

Purpose

Admin tests the public site on a real phone without a second login. The phone's anonymous pageviews appear live on the PC dashboard's Live Activity feed, attributed to the admin's identity. The phone never logs in — attribution happens through a short-lived pairing code and an HMAC-signed cookie.

Flow

  1. Admin visits the analytics dashboard on PC and requests a new pairing code (POST /api/admin/analytics/pair).
  2. Server generates a 6-digit code and returns it alongside a QR code (SVG, server-rendered) and the full URL /pair/<code>.
  3. Admin scans the QR or reads the code aloud; phone navigates to /pair/<code>.
  4. Server atomically claims the code: sets consumed_at, stamps the phone's _v10r_sid session row with paired_admin_user_id + paired_at, and sets the v10r_debug_owner HMAC cookie.
  5. Phone is redirected to /.
  6. All subsequent pageviews from that phone carry debug_owner_id = adminUserId in analytics.events.

Schema

analytics.pairing_codes

Column Type Notes
code text PK 6 digits in [2-9]. PG CHECK enforces format.
admin_user_id text FK → auth.user Cascades on user delete.
created_at timestamptz Default now.
expires_at timestamptz 10 minutes after creation.
consumed_at timestamptz Null until claimed.
consumed_by_session_id text FK → sessions Set null on session delete.
attempt_count integer Incremented on every claim attempt. PG CHECK ≤ 5.

analytics.eventsdebug_owner_id text nullable column, with partial index (debug_owner_id, id) WHERE debug_owner_id IS NOT NULL.

analytics.sessionspaired_admin_user_id text nullable, paired_at timestamptz nullable. Pairing tag is cleared by the cleanup job after 2h. A separate debug_owner_id column on sessions is the PERMANENT attribution copy: stamped on write, never cleared, and it is what every aggregate excludes on — the 2h cap bounds live streaming, not the exclusion of the operator's own traffic from the numbers.

Consent boundary (since 2026-08): the phone-side claim no longer mints _v10r_sid unconditionally. With analytics consent it uses/mints the cookie session as before; without it, it tags the cookieless daily session id instead — no terminal storage is written below the consent tier. Caveat: the cookieless id rotates at UTC midnight, so a pairing spanning midnight stops tagging the phone's new session for the remainder of the cap.

Code design

  • Alphabet: 23456789 — no 0, 1, O, I to eliminate visual ambiguity.
  • Code length: 6 digits → ~16.7M combinations, enough for the 10-minute TTL window.
  • TTL: 10 minutes (unconsumed). Enforced by both the WHERE expires_at > now query predicate and the cleanup job.
  • Attempt cap: 5 tries. Checked via a WHERE attempt_count < 5 predicate on the atomic UPDATE. Subsequent reads still increment attempt_count against brute-force.
  • Code generation retries on PK collision (up to 5 attempts), then raises.
  • claimPairingCode() is a single UPDATE ... RETURNING — atomic, no TOCTOU gap.

Name: v10r_debug_owner
Attributes: HttpOnly; Secure; SameSite=Lax; Max-Age=7200 (2h)

Value: a signed ticket — security/ticket.ts over { adminUserId }, in the format base64url(payload).base64url(hmac).

  • Keyed by the pairingOwner subkey, derived from BETTER_AUTH_SECRET via HMAC domain separation. PAIRING_SECRET is the optional rotation override, not a required input.
  • HMAC-SHA256 via node:crypto, verified with timingSafeEqual. The signature is checked before the payload is parsed, so untrusted structure is never interpreted unauthenticated.
  • Expiry is signed alongside the fields and enforced by verifyTicket, so a client cannot extend its own validity.
  • base64url is encoding, not encryption: the admin's user id is readable by anyone who can read the cookie. It is HttpOnly, and the cookie grants attribution rather than authority.
  • debugOwnerLoader in hooks.server.ts verifies the cookie on every request. If verification fails or the cookie is expired, the cookie is cleared and event.locals.debugOwnerId is set to null. The hook still catches, so any crypto failure fails closed without crashing.

Hook chain position

... → csrfProtection → sessionPopulate → consentLoader → debugOwnerLoader → devRouteGuard → analyticsCollector

debugOwnerLoader runs after consentLoader (consent tier is available) and before devRouteGuard (admin pages also get a populated debugOwnerId). analyticsCollector reads event.locals.debugOwnerId and passes it to recordEvent() / upsertSession().

Cleanup

analyticsCleanup() at src/lib/server/jobs/analytics-cleanup.ts handles three sweeps on each run:

Target Condition Retention
Unconsumed pairing codes consumed_at IS NULL AND expires_at < now - 1h 1h grace after expiry
Consumed pairing codes consumed_at < now - 7d 7 days
Paired session tags paired_at IS NOT NULL AND paired_at < now - 2h 2h hard cap — clears paired_admin_user_id + paired_at

The 2h session cap is a privacy guardrail: admin re-pairs if longer coverage is needed.

Threat model and limits

Concern Mitigation
Code brute-force 5-attempt cap + 10-min TTL; cap enforced at DB level (PG CHECK)
Stale attribution 2h hard cap; cleanup job untags sessions automatically
Admin revokes pairing revokePairing() marks code consumed, untags all sessions for that admin
Phone self-disconnects POST /api/pair/disconnect clears v10r_debug_owner cookie; future requests fail HMAC
Forged cookie HMAC-SHA256 + constant-time compare over a purpose-separated subkey; a ticket minted for any other purpose fails the MAC
Admin sees other users' events By default the live feed shows all recent site traffic and tags paired rows (isPaired). The phone-attribution guarantee is the debug_owner_id stamping, not a feed restriction. Selecting the paired filter restricts the feed to debug_owner_id = adminUserId.

Environment

No required variable. The signing key is derived from BETTER_AUTH_SECRET, which the app already refuses to boot without, so pairing works in any environment that can serve a request at all.

PAIRING_SECRET remains available as an optional override when this one key should rotate independently of the auth secret:

PAIRING_SECRET=<≥32 random bytes, base64-encoded>   # optional

Generate with:

openssl rand -base64 32

Rotating it ends every paired phone session immediately; they re-pair by scanning a new code.

← Back to Blueprint

Think this pattern could be better? Tell us how.

Leave feedback