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 -- --checkThe 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.1over--all --full-history: one finding.docs/FIVE-MINUTE-TEST.md(commit978d611) matchedcurl-auth-headeron the documented placeholdernp_test_YOUR_KEY. Same placeholder is inREADME.md. Not a live key. Triaged;.gitleaks.tomlallowlists that exact string. -
trufflehog
v3.90.8git file://. --only-verified: zero verified secrets, zero unverified secrets. -
Git history still contains the
978d611blobs forpackages/takehome-py/python/takehome_ca/_native.abi3.soand two.pycfiles, removed from the tree in482cef4. Those are build artifacts, not credentials. They stay until someone chooses a history rewrite.scripts/tracked-ban.shis the check so they cannot re-enterHEAD.
Stage 5 scan (2026-09-20)
-
gitleaks
v8.24.3with--log-opts=--all(full history): no leaks found. Allowlisted placeholder still in.gitleaks.toml. -
trufflehog
v3.90.8/3.97.5runtimegit file://. --only-verified: 0 verified, 0 unverified. -
LICENSE-APACHE(and package LICENSE files) replaced with the full Apache-2.0 text. -
services/apistays 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.zstSHA-25600a68d0bef8835f8271039981c22bcaca54ff5f0786c33fd3c5b346b27a0023f(catalog digest554a54278727bb460e7f2710cde2b6e31b750ea57c4a4ee6735eb2e4d02a71ea). Tarball is not in git.