Security Guide
This document covers the security architecture, known risks, applied hardening measures, accepted tradeoffs, and penetration testing procedures for The Pool crowdfunding platform.
Security Architecture
Authentication Mechanisms
| Mechanism | Endpoints | Description |
|---|---|---|
| Magic Link Tokens | /pledge*, /pledges, /votes |
HMAC-SHA256 signed tokens with 90-day expiry |
| Launch Reminder Unsubscribe Tokens | GET /launch-reminders/unsubscribe |
Scoped HMAC token that suppresses one campaign/email reminder signup |
| Stripe Webhook Signature | /webhooks/stripe |
HMAC-SHA256 verification per Stripe spec |
| Admin Dashboard Sessions | Browser dashboard /admin/* APIs |
Email magic-link sign-in, signed session cookie, CSRF header on mutations, role/campaign scoping |
| Admin Sign-In Challenge | POST /admin/auth/start |
Optional Cloudflare Turnstile verification before admin magic-link issuance |
| Launch Reminder Challenge | POST /launch-reminders |
Optional/expected Cloudflare Turnstile verification before reminder signup writes |
| Admin Recovery Secret | Automation and recovery /admin/* endpoints |
Authorization: Bearer <secret> or x-admin-key header for script-driven operations |
| Test Mode Guard | /test/* |
APP_MODE === 'test' environment check |
Data Storage (Cloudflare KV)
| Key Pattern | Namespace | Data | Sensitivity |
|---|---|---|---|
pledge:{orderId} |
PLEDGES | Email, amount, Stripe IDs, status | High - PII + payment data |
email:{email} |
PLEDGES | Array of order IDs | Medium - links email to pledges |
stats:{slug} |
PLEDGES | Aggregate totals | Low - public |
tier-inventory:{slug} |
PLEDGES | Tier claim counts | Low - public |
stripe-event:{id} |
PLEDGES | “processed” flag | Low - idempotency |
campaign-pledges:{slug} |
PLEDGES | Array of order IDs per campaign | Low - index |
campaign-charged:{slug} |
PLEDGES | Settlement completion timestamp | Low - flag |
settlement-job:{slug} |
PLEDGES | Settlement batch progress | Low - ephemeral |
pending-extras:{orderId} |
PLEDGES | Temporary support item / custom amount checkout extras | Low - ephemeral |
pending-tiers:{orderId} |
PLEDGES | Temporary overflow tier metadata during checkout | Low - ephemeral |
cron:lastRun |
PLEDGES | Last persisted hourly cron execution timestamp | Low - monitoring |
admin-login:{hash} |
PLEDGES | One-time admin login nonce and email | Medium - ephemeral admin auth |
admin-session:{hash} |
PLEDGES | Admin email, role, campaign scope, CSRF token, expiry | High - admin auth |
admin-users:v1 |
PLEDGES | Runtime admin users and campaign scopes | High - access control |
admin-marketing-referrals:{slug} |
PLEDGES | Saved referral code metadata | Low - admin-authored marketing data |
admin-audit:{date}:{action}:{id} |
PLEDGES | Recent admin mutation audit events | Medium - admin identity + operational metadata |
launch-reminder:{slug}:{emailHash} |
PLEDGES | Upcoming-campaign reminder email and opt-in metadata | Medium - campaign-scoped email |
launch-reminder-suppressed:{slug}:{emailHash} |
PLEDGES | Reminder suppression marker | Medium - campaign-scoped email hash |
launch-reminder-sent:{slug}:{emailHash} |
PLEDGES | Reminder send idempotency marker | Low - send state |
launch-reminder-dispatch:{slug} |
PLEDGES | Bounded reminder dispatch job cursor/progress | Low - operational state |
launch-reminder-dispatch-queue:v1 |
PLEDGES | Reminder dispatch queue idle/pending marker | Low - operational state |
supporter-email-retry:{orderId} |
PLEDGES | Queued supporter confirmation email retry payload | Medium - supporter email payload |
supporter-email-retry-queue:v1 |
PLEDGES | Supporter email retry idle/pending and next-attempt marker | Low - operational state |
add-on-inventory-sold:v1 |
PLEDGES | Platform add-on sold-count projection | Low - aggregate inventory state |
vote:{slug}:{decision}:{email} |
VOTES | Vote choice | Medium - links supporter to vote |
results:{slug}:{decision} |
VOTES | Vote tallies | Low - semi-public |
rl:{endpoint}:{ip} |
RATELIMIT | Request count + reset time | Low - ephemeral |
Scarce limited-tier reservation and committed-count truth is no longer stored in KV. That race-sensitive state now lives in the per-campaign Durable Object coordinator, while KV keeps only the public tier-inventory:{slug} projection.
Security Hardening Overview
The current security posture is designed around a few core principles:
- keep pricing, pledge state, and settlement server-canonical
- scope supporter access as narrowly as possible
- fail closed when secrets or environment checks are missing
- keep browser storage and cacheable responses low-sensitivity by default
- validate authored content and request payloads before they reach sensitive logic
- preserve operational visibility through repeatable security testing and explicit secrets handling
Access Control And Environment Gating
- magic links are scoped to specific pledge and campaign paths rather than broad user accounts
- private admin access uses email magic links, signed session cookies, CSRF checks, and role/campaign scoping
- admin sign-in can require a Cloudflare Turnstile challenge before login nonce writes or magic-link delivery
/test/*routes are gated behind test mode and are not meant to be reachable in normal deployments- admin routes require an explicit secret and are intended to fail closed when not configured correctly
- supporter voting is keyed to the supporter email identity associated with the authorized pledge, which prevents simple multi-pledge vote amplification
Webhook, Admin, And Origin Protections
- Stripe webhook handling is built around signature verification and an explicit configured secret
- admin-secret comparison is timing-safe rather than using a naive direct comparison
- sensitive browser POST flows such as checkout bootstrap, completion, and payment-method updates are origin-checked against the configured site base
- legacy callback surfaces that no longer belong to the live payment flow are intentionally removed rather than left dormant
Browser And Response Hardening
- order-specific checkout bootstrap and completion responses are served with
Cache-Control: private, no-store - long-lived browser persistence is limited to cart structure and pricing inputs, while contact and address drafts stay session-scoped
- short-lived recovery markers are used for checkout continuity instead of leaving sensitive in-flight state in storage indefinitely
- security response headers reduce MIME sniffing, framing risk, and unnecessary referrer leakage
Input And Content Validation
- checkout-start payloads validate campaign identifiers, email addresses, cart items, and contribution inputs before canonical reconstruction
- voting endpoints validate decision identifiers and option values before they reach state-changing logic
- dashboard settings, campaign fields, content blocks, add-ons, tiers, support items, diary entries, decisions, and user records are normalized server-side before persistence
- dashboard media uploads are scoped by role, campaign access, upload kind, content type, file size, destination directory, and canonical filename
- creator-authored labels and rich content are escaped or sanitized by default, with only a very small allowlisted HTML subset preserved
- structured embeds are allowlisted to exact approved providers and URL shapes instead of broad substring checks
- markdown link destinations are constrained to safe schemes and internal links
Inventory And Data Integrity
- scarce limited-tier inventory is coordinated through a per-campaign Durable Object rather than trusting client-visible KV state for race-sensitive truth
- public inventory remains a projection for efficient reads, while reservation and commit truth stays in the coordinator
- checkout completion invalidates cached stats and inventory so restored pages do not keep showing stale pre-pledge totals
- settlement and reporting depend on server-owned pledge records rather than browser-submitted totals
Abuse Controls And Operational Safeguards
- rate limiting is available for expensive routes such as checkout, pledge management, admin operations, and webhooks
- blocked requests are designed to fail closed without turning abuse into excessive extra KV writes
- normal dashboard reads, filters, previews, analytics, report downloads, and local editor drafts are designed to avoid KV writes
- secret values remain in Worker secrets or ignored local files; the dashboard can report configured/missing status but cannot edit or serialize secret values
- the secret-audit and security test suites are part of the documented verification path
- the security model assumes operators will keep deployment secrets rotated, scoped, and out of repository history
Accepted Boundaries
Some tradeoffs remain intentional in the current model:
- magic links are long-lived because accountless pledge management has to remain usable across campaign timelines
- tokens still arrive through emailed URLs, so the platform relies on scoped access, response headers, and limited browser persistence rather than a full token-exchange flow
If a deployment needs a stricter posture than that default, the most likely next steps would be shorter token lifetimes, easier token reissue flows, and a one-time token exchange that removes raw tokens from visible URLs after entry.
Secrets Checklist
Before deploying to production, verify these secrets are set:
| Secret | Environment Variable | Min Length |
|---|---|---|
| Stripe API Key | STRIPE_SECRET_KEY_LIVE |
N/A |
| Stripe Webhook Secret | STRIPE_WEBHOOK_SECRET_LIVE |
32+ chars |
| Checkout Intent Secret | CHECKOUT_INTENT_SECRET |
32+ chars |
| Magic Link Secret | MAGIC_LINK_SECRET |
32+ chars |
| Launch Reminder Token Secret | LAUNCH_REMINDER_TOKEN_SECRET or MAGIC_LINK_SECRET fallback |
32+ chars |
| Admin Session Secret | ADMIN_SESSION_SECRET |
32+ chars |
| Admin Secret | ADMIN_SECRET |
32+ chars |
| Turnstile Secret | TURNSTILE_SECRET_KEY, ADMIN_TURNSTILE_SECRET_KEY, or LAUNCH_REMINDER_TURNSTILE_SECRET_KEY |
N/A |
| Resend API Key | RESEND_API_KEY |
N/A |
Generate secure secrets:
openssl rand -base64 32
Penetration Testing
See tests/security/README.md for the pen test suite.
Run security tests:
npm run test:secrets # Audit local secret exposure in files + history
npm run test:security # Against local Worker
npm run test:security:staging # Against a staging worker, if you maintain one
npm run test:premerge now includes the secret audit automatically, so local merge gating checks both security behavior and accidental credential exposure.
For local runs, keep CHECKOUT_INTENT_SECRET configured if you want the live-worker checkout-start suite to exercise the real first-party signing path.
Incident Response
Token Compromise
If a magic link token is compromised:
- The token is tied to a specific orderId/email/campaign
- It can only access/modify that one authorized order
- To invalidate: delete the pledge from KV (
GET /pledgewill then return404for that token) - Optionally: regenerate MAGIC_LINK_SECRET (invalidates ALL tokens)
Admin Session Or Secret Compromise
- Immediately rotate
ADMIN_SESSION_SECRETandADMIN_SECRETviawrangler secret put - Clear active
admin-session:*keys from the Worker KV namespace - Review
admin-audit:*events and GitHub commits for unauthorized admin actions - Re-check campaign stats, pledge data, settings, and admin user scopes
Stripe Webhook Secret Compromise
- Rotate the webhook secret in Stripe Dashboard → Webhooks
- Update
STRIPE_WEBHOOK_SECRET_*in Worker - Check for any suspicious pledges created during exposure window
Missed Stripe Webhook (Development)
If the on-site payment step completes but the pledge doesn’t appear yet (common in local dev when webhook forwarding is delayed or broken):
- Check Stripe CLI output for webhook delivery status
- The client will first try
/checkout-intent/completeautomatically for local recovery, but if the pledge still does not appear, use the admin recovery endpoint to manually create it:curl -X POST http://localhost:8787/admin/recover-checkout \ -H 'Authorization: Bearer YOUR_ADMIN_SECRET' \ -H 'Content-Type: application/json' \ -d '{"sessionId": "cs_test_..."}' - The endpoint fetches the checkout session from Stripe and creates the pledge if it doesn’t exist
Prevention:
- Use
scripts/dev.shwhich runs the Worker with local KV simulation scripts/dev.shstarts a single Stripe listener, forwards events to127.0.0.1:8787/webhooks/stripe, writes that same listener’swhsec_...secret intoworker/.dev.vars, and clears stale local processes on the standard dev ports before startup- If you start Stripe manually, use the same listener instance for forwarding and for the secret you copy into local config
./scripts/dev.sh --podmanis the easiest way to keep the local site/Worker boundary production-like without relying on host Ruby/Wrangler setup- For testing with seeded data, run
./scripts/seed-all-campaigns.shafter starting the worker
Security Contacts
- Stripe Security: stripe.com/docs/security
- Cloudflare Status: cloudflarestatus.com