Docs · Reference

Secrets index

This file lists every secret Takehome touches. It contains **no secret values**. If a value appears here, that is a defect: rotate the secret and fix the commit.

Operator: the person who can log into Cloudflare, Stripe, npm, and PyPI for this product. Today that is the repository owner.

Rotation is always at the provider first. History rewriting is optional and never a substitute for rotation.


Provider credentials (not in git)

Secret What it is Where it lives Who rotates If it leaks
Cloudflare API token Wrangler / Pages / Worker deploy, D1 admin Operator machine (wrangler login session or CLOUDFLARE_API_TOKEN). Never committed. Set via wrangler secret / dashboard, not wrangler.toml. Operator, Cloudflare dashboard → API Tokens. Burn the token. Attacker can deploy code, read D1 (accounts, hashed keys, webhook secrets), change DNS/routes on takehome.gautamkhosla.com.
Stripe secret key (STRIPE_SECRET_KEY) Server-side Stripe API Cloudflare Worker secret (wrangler secret put STRIPE_SECRET_KEY). Read in services/api/src/stripe.js. Operator, Stripe dashboard → API keys. Roll the secret key. Attacker can create Checkout sessions, read customers, and change subscriptions.
Stripe webhook signing secret (STRIPE_WEBHOOK_SECRET) Would verify Stripe webhook signatures once billing returns Not used today: billing is deferred (ADR-006) and POST /v1/billing/webhook answers 404 without reading a secret. If billing returns, set it with wrangler secret put STRIPE_WEBHOOK_SECRET. Operator, Stripe dashboard (webhook endpoint signing secret). Today nothing: no route accepts Stripe events. Once billing returns, an attacker with it could forge billing events.
Stripe price IDs (STRIPE_PRICE_STARTER, STRIPE_PRICE_GROWTH, STRIPE_PRICE_BUSINESS) Live Stripe Price objects for CAD plans Cloudflare Worker vars/secrets. Tests use non-secret placeholders price_starter_cad / price_growth_cad / price_business_cad. Operator, Stripe dashboard. Not a credential. A wrong ID bills the wrong product. Treat production IDs as production config, not git fixtures.
GitHub Actions GITHUB_TOKEN rules-watch opens issues on T4127 pin drift; publish.yml attaches SBOMs to the GitHub Release Injected by GitHub Actions (secrets.GITHUB_TOKEN). Job-level permissions: only: issues: write on rules-watch, contents: write + id-token: write on npm publish. GitHub (ephemeral per job). If a workflow is compromised, lock the workflow and review Actions history. Attacker with a writeable workflow can open issues or attach release assets; they cannot push unless a PAT with contents: write is added: do not add one.

takehome-ca does not use NPM_TOKEN or PYPI_API_TOKEN. Publish is GitHub Actions OIDC: npm Trusted Publisher (npm publish --provenance) and PyPI Trusted Publishing (pypa/gh-action-pypi-publish). Those are provider-side bindings to .github/workflows/publish.yml, not secrets in this repo. Configure them on npmjs.com and pypi.org before the first Release. scripts/publish-packages.sh exits 1 so a laptop cannot ship a tarball.


Secrets issued to customers (not in git)

Secret What it is Where it lives Who rotates If it leaks
np_test_ / np_live_ API keys Bearer keys for /v1/deductions*. Live keys are metered. Shown once at signup verify. Stored in D1 as SHA-256 (services/api/src/keys.js). Plaintext is not retained. Customer: POST /v1/keys/rotate or /v1/keys/revoke (the live key needs the live key), or DELETE /v1/account. Operator can delete the D1 rows. Attacker can run calculations as that account. Live keys can exhaust the plan; they cannot move money.
Customer webhook signing secrets (whsec_…) 256-bit HMAC key for takehome-signature on rule_set.changed deliveries Generated at POST webhook register. Returned once in the create response (GET never includes it). Stored plaintext in D1 webhook_endpoints.secret. Customer deletes and re-registers the endpoint (new secret). Operator can DELETE the row. Attacker can forge rule-change payloads to that customer’s webhook URL. Rotate by deleting the endpoint.
Signup verification tokens 16 random bytes (32 hex characters) in /signup/verify/?token= Hashed (SHA-256) in D1 email_tokens with a 24-hour expiry, the accepted terms time and version, and send_key (the normalised address the resend cooldown counts: lower case and no +tag on every domain; dots dropped only for Gmail). Used once by POST /v1/signup/verify. Each send also adds a row to verification_sends with the send time, the client network (IPv4 address or IPv6 /48), a random send id and a SHA-256 hash of the normalised address (never the address, no account id; it keeps the per-address limits after the link is used or the account is deleted, kept at most 48 hours, unless every hourly cleanup of a whole day fails): it enforces the 30-a-day per-network budget (429) and feeds the signup_mail_volume alert at 80 in 24 hours (an alert only, never a block); if Resend refuses the email, its link is deleted at once, the row loses its address hash (so it still counts toward the per-network budget but toward no per-address limit) and an account row left with nothing is deleted too (security round 22, L1); every row is deleted by the hourly cleanup once it is older than 24 hours. Deleted when used; the link deleted at once if Resend answers with an error; kept until expiry if Resend did not reply (it may have been delivered; signup answers 503 "We could not confirm the verification email was sent. If it does not arrive, try again in 10 minutes."); expired links removed by the hourly cleanup. Operator can delete the D1 row. Attacker who has the URL can press the button and issue keys for that email. See *Signup mail* below.

Identifiers that are not credentials

These are in git on purpose. They do not grant access by themselves.

Identifier Where Notes
D1 database_id / database_name services/api/wrangler.toml ([[d1_databases]]) Cloudflare UUID for database takehome. Binding is DB. Access still requires a Cloudflare token. If the token leaks, this ID tells an attacker which database to query: rotate the token, not the UUID.
Worker name / Pages project takehome-api, Pages project takehome Public origin takehome.gautamkhosla.com.
Stripe test placeholders services/api/tests/helpers.js, scripts/local.mjs price_*_cad strings. Not Stripe objects.

Rules signing key (Ed25519)

Item Where Notes
Private seed ~/.config/takehome/rules-signing.seed on the operator's machine (folder 700, file 600), or the path in TAKEHOME_RULES_SEED_FILE Never in git, a Worker secret, CI or a chat. scripts/sign-rules.mjs refuses a seed path inside the repository and never prints the seed. scripts/tracked-ban.sh (CI) fails if any *.seed, *.key or *.pem file is tracked.
Public key TRUSTED_RULES_PUBLIC_KEY in crates/takehome-core/src/rules/signature.rs (and in data/rules/signatures.json) The engine verifies with the pinned key only. Revoked keys are listed in REVOKED_RULES_PUBLIC_KEYS.

Back it up: store the seed file's contents in the operator's password manager, and keep one offline copy (for example an encrypted USB key). If it is lost, rules can no longer be signed with this key: generate a new one and ship a new engine (rotation below).

Sign a new edition: edit data/rules/, then

node scripts/sign-rules.mjs            # prints the public key only
bash scripts/vendor-core-rules.sh
bash packages/takehome-js/scripts/build.sh && bash scripts/record-wasm-hash.sh
cargo run -p takehome-conformance -- --write && cargo run -p takehome-conformance -- --check

The printed public key must equal TRUSTED_RULES_PUBLIC_KEY.

The claim-code tables (data/rules/*/claim-codes/) are not in the signed bundle yet: since security round 14 the engine reads them and pins each embedded file by SHA-512 in crates/takehome-core/src/rules/claim_codes.rs instead. To sign them, add their paths to scripts/sign-rules.mjs and verify them in rules/loader.rs like the other rule files.

Rotate (if the seed leaks): move the old seed aside, run node scripts/sign-rules.mjs --generate (it refuses to overwrite), put the new public key in TRUSTED_RULES_PUBLIC_KEY, add the old one to REVOKED_RULES_PUBLIC_KEYS, then sign and rebuild as above. Done once on 2026-10-22 (audit X1): the first key's seed had been committed.

Signup mail (Resend)

Production and staging send verification and alert mail through Resend.

Item Where Notes
RESEND_API_KEY Worker secret (wrangler secret put RESEND_API_KEY) Never in git or wrangler.toml. Required when MAILBOX is absent; without it every signup answers 503. scripts/deploy-prep.sh checks that the name is in wrangler secret list (names only).
MAIL_FROM wrangler.toml [vars] / [env.staging.vars] Takehome <noreply@gautamkhosla.com>. Domain must be verified in Resend (SPF/DKIM).
MAIL_REPLY_TO wrangler.toml [vars] Human-readable reply address on verification mail (not a noreply).
ALERT_EMAIL Worker secret (required; deploy-prep checks the name) Operator inbox for quota 402 and other A09 alerts. Without it no alert is emailed (they are still logged). Emails are capped across every server instance in D1 (security rounds 23 and 24, L1): each event type at most once a UTC clock hour and once a UTC day (a database failure 4 times), so at most 16 a day; each instance also throttles to one per event type per hour and 12 a day, of which events requests and signups can cause use at most 6 (the signup mail volume at most once a day), and 4 a day are kept for a database or cleanup failure. The rest are only logged; while D1 cannot answer, no request emails at all, and only the hourly cleanup emails that it failed, once a run, so at most 24 a day (security round 25, L2). Staging's own D1 counts staging's alerts separately (16 a day, the same Resend account); leave it unset on staging to stop them.
ALERT_RESEND_API_KEY Worker secret (required; deploy-prep checks the name) A second Resend key used only for alert emails, so it can be revoked or rotated on its own. It is in the same Resend account and domain as RESEND_API_KEY; as far as we know Resend counts the sending quota per account, not per key (not verified), so it does not give alerts a quota of their own. The code falls back to RESEND_API_KEY without it (still throttled), which is why the deploy refuses to go ahead without it.
SIGNUP_EMAIL_ALLOWLIST Staging Worker secret only (npx wrangler secret put SIGNUP_EMAIL_ALLOWLIST --env staging) Comma-separated test addresses that staging signup may email (SIGNUP_ALLOWLIST_ONLY = "1" in [env.staging.vars]). Every other address is refused before anything is stored or sent; without the secret, every address is. Never set on production.
LIVE_CALLS_PER_DAY Optional wrangler.toml var Lowers the daily cap of live calls per account (3,600 in the code); a higher or malformed value keeps 3,600 and is alerted as rate_limit_config_invalid.
TURNSTILE_SECRET_KEY Worker secret (cd services/api && npx wrangler secret put TURNSTILE_SECRET_KEY; staging: add --env staging) Cloudflare Turnstile secret for siteverify. Gautam sets it himself; never printed, never in git, a file or wrangler.toml. Missing means every signup is refused (fail closed) and turnstile_unavailable is alerted. scripts/deploy-prep.sh checks that the name is in wrangler secret list (names only).
TURNSTILE_HOSTNAME wrangler.toml [vars] / [env.staging.vars] The exact hostname siteverify must report: takehome.gautamkhosla.com (staging takehome-staging.gautamkhosla.com). No wildcard.
PUBLIC_TURNSTILE_SITE_KEY Committed in web/site/src/lib/turnstile.js (0x4AAAAAAFitxLPtUwOza20N); the build env var overrides it The public site key of the Turnstile widget (Managed mode, hostnames takehome.gautamkhosla.com and takehome-staging.gautamkhosla.com, created 23 Oct 2026). Not a secret: every signup page carries it. deploy-prep refuses the placeholder TURNSTILE_SITE_KEY_NOT_SET and Cloudflare's test keys. Test keys (1x00000000000000000000AA site, 1x0000000000000000000000000000000AA secret) are for tests and local dev only, and the Worker refuses any siteverify answer marked result_with_testing_key. npm run local (services/api/scripts/local.mjs) uses a local stand-in for siteverify instead.
SMOKE_API_KEY Environment variable on the operator's machine, only while running scripts/smoke-live.mjs An np_test_ key from an account Gautam owns; the smoke test exits 1 without it. Never in git, a file or a Worker secret.

sendMail in services/api/src/mail.js: if env.MAILBOX is set (tests / npm run local), push in-memory and return. Else POST to https://api.resend.com/emails. If neither MAILBOX nor RESEND_API_KEY is set, signup returns 503 mail (fail closed). Alert mail failures are logged as alert_mail_failed and do not change the HTTP response.

ECHO_VERIFY_URL is not a production var. It is set only in gitignored services/api/.dev.vars and in the test harness (ECHO_VERIFY_URL: '1'). The Worker treats any value other than the exact string "1" as off, including absent, empty, "0", "false", and "true". A tracked [vars] assignment would return the verification URL (and therefore the signup token) to anyone who can POST /v1/signup.


Operator-local, never git

Item Notes
~/.npmrc auth token Operator npm login for installs. Gitignored as .npmrc. Do not put a publish token here. takehome-ca publishes from .github/workflows/publish.yml via OIDC (npm publish --provenance). Configure an npm Trusted Publisher for that workflow; revoke any leftover classic NPM_TOKEN.
~/.pypirc / TWINE_PASSWORD / PYPI_API_TOKEN Not used. PyPI Trusted Publishing on publish.yml (pypa/gh-action-pypi-publish + id-token: write). If an old API token exists at pypi.org, revoke it.
Wrangler OAuth session ~/.wrangler / .wrangler/ (gitignored).
.env / .dev.vars Must never be committed. Gitignored. Worker secrets go through wrangler secret put.

Stage 0 scan (2026-09-19)

  • gitleaks v8.30.1 over --all --full-history: one finding. docs/FIVE-MINUTE-TEST.md (commit 978d611) matched curl-auth-header on the documented placeholder np_test_YOUR_KEY. Same placeholder is in README.md. Not a live key. Triaged; .gitleaks.toml allowlists that exact string.
  • trufflehog v3.90.8 git file://. --only-verified: zero verified secrets, zero unverified secrets.
  • Git history still contains the 978d611 blobs for packages/takehome-py/python/takehome_ca/_native.abi3.so and two .pyc files, removed from the tree in 482cef4. Those are build artifacts, not credentials. They stay until someone chooses a history rewrite. scripts/tracked-ban.sh is the check so they cannot re-enter HEAD.

Stage 5 scan (2026-09-20)

  • gitleaks v8.24.3 with --log-opts=--all (full history): no leaks found. Allowlisted placeholder still in .gitleaks.toml.
  • trufflehog v3.90.8 / 3.97.5 runtime git file://. --only-verified: 0 verified, 0 unverified.
  • LICENSE-APACHE (and package LICENSE files) replaced with the full Apache-2.0 text.
  • services/api stays in the public tree; README licence split states the hosted product is metered, Worker source remains Apache-2.0.
  • Conformance corpus archive rebuilt: takehome-conformance-corpus-2026.1.tar.zst SHA-256 00a68d0bef8835f8271039981c22bcaca54ff5f0786c33fd3c5b346b27a0023f (catalog digest 554a54278727bb460e7f2710cde2b6e31b750ea57c4a4ee6735eb2e4d02a71ea). Tarball is not in git.

Source: docs/SECRETS.md in the Takehome repository, rendered as written.