Environment Variables
Securely store API keys, database URLs, and secrets. Encrypted at rest and only available to your backend functions.
On this page
Site vs Global Secrets — two stores, on purpose
There are two places a secret can live, matching the two kinds of functions:
| Site variables | Global secrets | |
|---|---|---|
| Belong to | one site | your whole account |
| Read by | that site's backend functions | account functions (Automations → Functions) |
| Set in | the site's Config tab | Automations → Secrets (or ask the AI) |
| Typical use | per-site keys — each client site's own Stripe key, CRM token | account-wide keys — your AI key, internal tools with no site |
| Duplicate names | adding a name that already exists is refused, so a working key is never silently replaced. To rotate a value, edit the entry in Automations → Secrets; in a site's Config, delete the variable and add it again, or ask the AI to update it | |
Why two stores, and no automatic fallback? Two reasons. First, the same name often needs different values per site — an agency running three client sites has three different STRIPE_SECRET_KEYs, and a global-only store couldn't express that. Second, explicit beats clever with secrets: a site function can never silently read an account-wide key, and a global automation can never silently pick up some site's value. Each function reads exactly one store — the one matching its scope — so there are no surprises about which secret was used. If a value is genuinely needed in both worlds, you set it in both, deliberately.
The same split explains the two kinds of functions: site backend functions belong to one site — they run with that site's data, members, and secrets, and its pages call them. Account functions belong to you — they span sites (or need no site at all) and are triggered by webhooks, email triggers, and schedules. See Account Functions & Webhooks.
Site Variables — create & use
A site variable belongs to one site and is read by that site's backend functions — the functions its pages call. To create one:
- Go to your app's Config tab in the dashboard
- Scroll to the Environment Variables section
- Enter a name (uppercase with underscores, e.g.,
STRIPE_SECRET_KEY) - Enter the value (your API key, secret, etc.)
- Click Add
Variable names must be alphanumeric with underscores only (e.g., MY_API_KEY). Values are masked in the UI after saving.
Then use it inside the site's backend functions through ctx.secrets.ref('NAME') — a marker that works only in that run. The function's code never sees the value: the platform swaps it in on the way out, only for the hosts on that function's list (its Access tab → Secrets). A non-secret setting still reads with ctx.env.get():
Which secrets belong here? API keys your functions send to a service. No code can read a secret's value, and each one goes only where you allowed it. For an email mailbox use a mailbox in Sending accounts (Automations → Sending accounts) and for a connected integration use connections: in both, the platform performs the privileged action and the credential never enters your function at all.
// STRIPE_SECRET_KEY is on this function's list, allowed to api.stripe.com.
const result = await ctx.fetch('https://api.stripe.com/v1/charges', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + ctx.secrets.ref('STRIPE_SECRET_KEY') },
body: 'amount=1000¤cy=usd',
});
// A webhook signature is checked with a secret the same way — true or false, the key never enters the code:
// (params._request is { body, headers } of the webhook call; the secret's key format is set on its list entry.)
const ok = await ctx.secrets.verifyHmac('STRIPE_WEBHOOK_SECRET', t + '.' + params._request.body, v1);
// A non-secret setting (a mode, a public id) still reads as a string:
const mode = await ctx.env.get('PAYMENT_MODE');
// A function can also save its own working values: a plain, readable setting it reads back
// (this site only; the name must start with APP_, and a secret you saved is never overwritten).
await ctx.env.set('APP_ONBOARDING_STAGE', 'complete');
const stage = await ctx.env.get('APP_ONBOARDING_STAGE');Global Secrets — create & use
A global secret belongs to your whole account and is used by your account functions — the ones triggered by email triggers, webhooks, and schedules, which may have no site at all. To create one:
- Go to Automations → Secrets in the dashboard (or just tell the AI: “save my Anthropic key as a global secret”)
- Click Add secret, enter the name (e.g.
ANTHROPIC_API_KEY) and paste the value - To change a value later, use the edit (pencil) action — adding a second secret with the same name is refused, so a working key is never replaced by accident
Account functions use it exactly the way site functions use their own store, once it is on their list (an agent uses the marker its web_fetch lists; a flow step writes {{secret:NAME}} in its settings):
// In an ACCOUNT function (e.g. one an email trigger runs), with ANTHROPIC_API_KEY on its list
// for api.anthropic.com — filled in by the platform, never seen by the code:
const res = await ctx.fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'x-api-key': ctx.secrets.ref('ANTHROPIC_API_KEY'), 'anthropic-version': '2023-06-01' },
body: JSON.stringify({ model: 'claude-sonnet-5', max_tokens: 300,
messages: [{ role: 'user', content: 'Classify this email…' }] }),
});
// Account functions can also store their own state, as plain settings they read back —
// only APP_-prefixed names, and never over a secret you saved:
await ctx.env.set('APP_LAST_SYNC_AT', new Date().toISOString());Same function code, different store: ctx.secrets.ref('NAME') in a site function uses that site's variables; in an account function it uses your global secrets. A function never has to say which store — its scope decides, and the two never mix.
Using a secret in a request (both stores)
In any function — site or account — put ctx.secrets.ref('NAME') (this run's marker for the secret) in ctx.fetch paths, query strings, headers and text or JSON bodies — never in the host name. They are filled in from the function's own store (site variables in site functions, global secrets in account functions) on the way out, only for a host on the function's list and within the secret's own allowed hosts. Text like {{secret:NAME}} or {{NAME}} is never filled in — a visitor, a webhook or a form could send the same words — so your code writing it is refused with a message saying to use ctx.secrets.ref.
// Filled in by the platform on the way out
const key = ctx.secrets.ref('API_KEY');
const result = await ctx.fetch('https://api.example.com/users?key=' + key, {
headers: {
'Authorization': 'Bearer ' + key,
},
});
// Basic auth: save the already-encoded credential as ONE secret (base64 of "id:secret")
await ctx.fetch('https://api.razorpay.com/v1/orders', { headers: { Authorization: 'Basic ' + ctx.secrets.ref('RAZORPAY_BASIC') } });Placeholders work in the URL's path and query, headers, and text or JSON bodies of ctx.fetch calls, including nested JSON. A reply that prints a listed secret comes back with it hidden.
From your site's pages (serverFetch)
Pages call serverFetch with the same placeholders. Because a visitor's browser chooses the address, a visitor's call fills in a secret only when the address's host is on that secret's Allowed hosts (Site Config → Environment Variables → Edit hosts), for example api.openai.com or *.example.com. With no hosts, visitors' calls that use the secret are refused. Your own preview is not limited, and tells you which host to add.
Where should a secret live?
| The secret is… | Put it in | Who can write it | Who can use it |
|---|---|---|---|
| A key YOU manage (Anthropic, Stripe…) | Site Config or Automations → Secrets | you (dashboard or your AI) — never app code | your functions (read-only) |
| A value the APP manages at runtime | APP_* env name via ctx.env.set | function code | function code |
| Sensitive data INSIDE a record (a customer's token, ID number) | a table secret field | whoever may edit the row | nobody reads it back — masked everywhere, even for the owner; people who may edit the row can replace it |
| An email mailbox password | Sending accounts (Automations → Sending accounts) | you | nobody — the platform sends on your behalf; code names the mailbox, never sees the password |
The rule behind the whole table: code can never overwrite a secret a human set (function writes are limited to the APP_* namespace at both scopes), and the most protected stores are the ones where the value never enters code at all.
Security (both stores)
AES-256-GCM encryption at rest
Values are encrypted before storage and only decrypted when your backend function executes.
Backend-only by default
Every variable created from the dashboard's Add Variable form is marked secret — used only by backend functions through ctx.secrets.ref('NAME') (their code never reads the value), never sent to the browser or included in the published bundle. A variable can only be made non-secret (baked into the client bundle at build time) programmatically via isSecret: false — don't do this for anything sensitive.
Masked in UI
After saving, a value is never shown again, not even partly. In a site's Config you can delete a variable and edit its allowed hosts. To change a value, delete it and add it again, or ask the AI to update it.
Tip: Don't use Deno.env or process.env in your functions — they're blocked by the sandbox. Use ctx.secrets.ref('NAME') for a secret and ctx.env.get() for a non-secret setting.