Threat model
Who can do what, where the trust boundaries are, and what each secret protects. Names only, never values.
A threat model answers three questions: what are we protecting, who might attack it, and what stops them. This page is a design map of GAVEL, read from the code. It is not an audit. The security checklist has 18 items and none is signed off yet (see the security review).
Everything here is the PayPal sandbox. No real money moves.
Threat model: trust boundaries and secrets
Scroll the figure sideways, or open it full size.
Everything on the map. Pick a secret to see only what it protects.
Hover, focus or tap a box for what it checks and which file does it. Only secret names appear here, never values.
packages/server/src/auth (token.ts, rotation.ts, authorize.ts), packages/server/src/http/ops.ts, http/auctions.ts, http/security-headers.ts, packages/server/src/config.ts, host/audio-ws.ts, packages/payments/src/guard.ts, render.yaml and docs/SECURITY-REVIEW.md. This is a design map, not an audit: all 18 items of the security checklist are still OPEN and no item has been signed off. Everything here targets the PayPal sandbox.Text version of this diagram
- Bidder browser. Joins with an invite token (24 h, single use) and then a session token (4 h). It sees its own ceiling and nobody else's:
redactForViewerinauth/authorize.tsdrops any ceiling that does not name the viewer. - Auctioneer browser. Holds an auctioneer token (12 h). It can control the auction and enter bids for bidders. It cannot create auctions or mint a successor link: both need the admin key (
http/auctions.ts,http/auctioneer-token.ts). - Screen display. Holds a screen token (12 h). A screen never receives ceilings or per-bidder frames, and sees public audit rows only.
- Operator. Sends the key in the
x-admin-keyheader. With it: create an auction, rotate or revoke auctioneer links, list rooms, void all holds or release a review (/ops/...), read metrics, export the ledger and replay bundle. This is the most powerful secret. - Stranger with a link. A token for another auction, an expired, revoked or edited one, or the wrong kind is refused (
verifyTokenreturns unauthorized). An invite that was already used answers invite_used. A leaked auctioneer link is killed by the rotate endpoint, which raises a per-auction floor. - Edge checks. Origin allowlist (
PUBLIC_URLplusCORS_ORIGINS) on HTTP and both sockets, per-source rate limits (RATE_*, behindTRUSTED_PROXY_HOPS), body size caps and baseline headers: CSPdefault-src 'none', HSTS, nosniff, no referrer (http/security-headers.ts). - Token door. A token is
payload.signature, HMAC-SHA256 withTOKEN_SECRET. The server checks the signature in constant time, then audience, lifetime, kind, identity binding and the revocation list. During a rotationTOKEN_SECRET_PREVIOUSis accepted untilTOKEN_SECRET_PREVIOUS_UNTIL. A secret under 32 bytes stops a production boot. - Audio door. The /audio socket takes the auctioneer token as its first message within 5 seconds, never in the URL, then enforces rate, size and duration limits. Audio before auth closes the socket with 4401.
- Admin door. The header value and
ADMIN_KEYare hashed with SHA-256 and compared withtimingSafeEqual(http/ops.ts). In production the key must be at least 24 characters and must differ fromTOKEN_SECRET(config.ts). - Webhook door. The raw body is verified before anything is read from it, through PayPal's own verification endpoint with
PAYPAL_WEBHOOK_ID(G4 PASS on real events; tampered-event rejection is covered against the fake only). The certificate URL host must be paypal.com or a subdomain. Bodies over 256 KiB are refused and only an event id and an outcome are logged. - Inside the host. One Node process. Nothing here runs before a door check has passed. The engine still validates every bid, whoever sent it.
- Room, engine, ledger. The engine accepts or rejects every bid and appends it to the hash-chained ledger. A role table (auth/authorize.ts) lists which role may do which operation; anything not listed is denied.
- Payments. Holds, settlement saga and journals. Only the sandbox API host over https is allowed (
payments/src/guard.ts),PAYPAL_ENVmust be sandbox, and fake payments are refused in a production-like setting. The admin key can void all holds or release a review through/ops; the audit row names the actor admin. - Voice bridge. Turns speech into bids that still go through the room gate and the engine. The bidder comes from the paddle that was heard, never from the token. Drop records leave the transcript out.
- Storage. Room log, ledger and settlement journals in files on a persistent disk (
/var/datainrender.yaml) or in Postgres throughDATABASE_URL. The memory store is refused in production unless explicitly allowed. - PayPal sandbox. Reached with OAuth client credentials (
PAYPAL_CLIENT_ID,PAYPAL_CLIENT_SECRET) and nothing else. It sends signed webhook events back. The host name is fixed to api-m.sandbox.paypal.com; a CI check fails the build if source names the live host. - Speech providers. Audio leaves the host only when
VOICE_STTnames Deepgram or Groq and its key (DEEPGRAM_API_KEY,GROQ_API_KEY) is set. The default build plays cached transcripts and sends nothing. The product tells people: "Audio is sent to a speech service to read bids. Do not speak personal data." - GitHub Actions. Gate and nightly workflows read the sandbox app and buyer secrets from repository secrets (names such as
PAYPAL_CLIENT_ID,SANDBOX_BUYER); values are masked in logs and never recorded. The gate workflows run in thesandbox-nightlyenvironment and only from main.
Boundary crossings: browsers and the operator reach the host over HTTPS and WebSocket carrying a signed token or the admin key. The host reaches PayPal with OAuth client credentials. PayPal reaches the host with a signed webhook event. The host reaches a speech provider with an API key only when one is configured. GitHub Actions reaches PayPal with the sandbox secrets.
For a beginner: the two secrets that matter most
Think of GAVEL as a building with two kinds of keys.
TOKEN_SECRET is the stamp the host uses to sign tickets. When you join an auction you are given a link that contains a ticket (a token). The ticket says who you are, which auction it is for, what you may do and when it expires. The host signs it with TOKEN_SECRET, so it can later tell a real ticket from a forged or edited one.
- Only the host knows the stamp. Browsers carry tickets, never the stamp.
- If someone learns TOKEN_SECRET they can print a valid ticket for anyone, including an auctioneer. That is why it must be at least 32 bytes in production and why it is rotated with a grace period (below).
- Tickets are short lived: invite 24 hours (single use), session 4 hours, auctioneer 12 hours, screen 12 hours.
Why two secrets and not one
A ticket can be copied from a browser or a screen share. The admin key never goes to a browser. Keeping them separate means a stolen ticket can never create auctions or move money, and a stolen admin key does not let anyone forge tickets. Both can be rotated on their own.
Every secret, by name
The values live only in the host's environment (Render marks them as not synced from the repository) and in GitHub repository secrets. They are never committed, logged or shown.
Prop
Type
Trust boundaries
A trust boundary is a line where data passes from something we do not control to something we do. Every crossing has a check.
| Crossing | What is trusted | What is checked | Where |
|---|---|---|---|
| Browser to host (HTTP and WebSocket) | Nothing | Origin allowlist, rate limits, body caps, then a signed token with audience, lifetime, kind and revocation | packages/server/src/auth/token.ts, host, http |
| Operator to host | The holder of ADMIN_KEY | Constant-time key check on every ops, export, create and rotate route | packages/server/src/http/ops.ts |
| Audio socket | The auctioneer token, once | Token as the first message within 5 seconds; rate, size and duration limits; the bidder comes from the paddle heard, not the token | packages/server/src/host/audio-ws.ts |
| PayPal to host (webhook) | Nothing in the body until verified | Raw body verified through PayPal's endpoint; certificate host must be paypal.com; 256 KiB cap | packages/payments/src/webhooks/verify.ts, http/webhook-guard.ts |
| Host to PayPal | The sandbox API host only | Exact host match on api-m.sandbox.paypal.com over https; PAYPAL_ENV must be sandbox | packages/payments/src/guard.ts |
| Host to speech provider | The provider, for the audio it is sent | Only when VOICE_STT names it and its key is set; the product warns people not to speak personal data | packages/server/src/voice/stt.ts |
| Host to disk or Postgres | The host's own storage | Files on one persistent disk, or a database; memory store refused in production unless allowed | packages/server/src/config.ts, render.yaml |
| CI to PayPal | Repository secrets | Gate workflows run in the sandbox-nightly environment and only from main | .github/workflows/gate-g*.yml |
What an attacker can try
A token is bound to one auction and one bidder identity. An invite is single use and burned when the session is created. A session token expires after 4 hours. A bidder sees only their own ceiling: the room removes every other ceiling from what it sends (redactForViewer).
The signature no longer matches, so the host answers unauthorized. The payload is strict: an unknown field, including an alg field, is rejected, so there is no algorithm to downgrade.
The operator rotates the link with ADMIN_KEY. That raises a per-auction floor: every earlier auctioneer token stops working on HTTP and on the socket, and open sockets of the revoked token close with 4401. The floor is stored, so it survives a restart.
The body is verified through PayPal before anything is read from it. A tampered event is rejected in tests against the fake PayPal. On the real sandbox, six real deliveries verified (G4); a tampered event was not part of that run.
Per-source rate limits for joins, bids and HTTP, connection limits per bidder and per room, a cap on bids per auction and size caps on bodies and audio frames. The defaults are in packages/server/src/config.ts. The limits are covered by the abuse and denial-of-service tests in packages/e2e/test/security.
The microphone is the room's, so a spoken bid names a paddle. It still passes the room gate and the engine: the bidder must be on the roster, the amount must be valid, and a low confidence or ambiguous bid is held and the real bidder is asked. G5 failed on synthetic audio, so spoken bids go through the confirm prompt.
Rotating a secret
TOKEN_SECRET. Set TOKEN_SECRET_PREVIOUS to the current value and TOKEN_SECRET_PREVIOUS_UNTIL to a deadline in epoch milliseconds. Set TOKEN_SECRET to the new value and restart. Tokens signed with either are accepted until the deadline. After it, remove the two PREVIOUS settings and restart.
ADMIN_KEY. Change it in the host environment and restart. Update the GitHub secret that holds the same value if a workflow uses it.
PayPal credentials. Rotate in the PayPal developer dashboard first, then update the environment.
A leaked auctioneer link. Call the auctioneer rotate endpoint with the admin key. Details are in the operator runbook.
What is not covered
Where this lives in the code
- packages/server/src/auth/token.tstoken format, kinds, lifetimes and verification
- packages/server/src/auth/rotation.tsTOKEN_SECRET and TOKEN_SECRET_PREVIOUS
- packages/server/src/auth/authorize.tsrole table and per-viewer redaction
- packages/server/src/http/ops.tsthe admin key check and ops routes
- packages/server/src/http/auctioneer-token.tsrotate and revoke auctioneer links
- packages/server/src/config.tssecret length rules and production rules
- packages/server/src/http/security-headers.tsbaseline response headers
- packages/payments/src/guard.tssandbox host only
- packages/payments/src/webhooks/verify.tswebhook verification
- docs/SECURITY-REVIEW.mdthe checklist, all items OPEN
- SECURITY.mdhow to report a vulnerability
Last updated on