TL;DR: The secrets vault holds the private keys your app needs but must never ship to the browser โ a Stripe secret key, a Resend or Twilio or ElevenLabs API key, any third-party credential. You store each one once under an env-var-style NAME; your functions read it server-side at run time as ctx.secrets.NAME. The browser never sees the value. Secrets are encrypted at rest with AES-256-GCM, the master key lives in the server environment (not the database), and the console lists names only โ values are write-once, never read back.
This is the counterpart to the anon key. The anon key is public by design โ it ships in your client bundle and row-level security does the gatekeeping. Secrets are the opposite: they are precisely the things that must stay private, and the vault exists so the value only ever lives server-side, used from a function. If you find yourself wanting to put a vendor's secret key somewhere the browser can reach, the answer is almost always "put it in the vault and read it from a function instead."
Most apps end up needing a credential they cannot expose. To charge a card you need your Stripe secret key. To send a transactional email you need a Resend key. To send an SMS you need a Twilio token; to synthesize speech, an ElevenLabs key. None of these can ship to the browser โ anyone who opens dev tools would have them, and could spend your money or send mail as you.
The vault solves this by giving each private key a home on the server. You store it once, your server-side code reads it at run time, and it never crosses the wire to the client. That's the whole idea: the value lives in exactly one place the browser can't see.
Each secret is stored under an env-var-style NAME (uppercase letters, digits, and underscores โ for example STRIPE_SECRET_KEY) and a value. The value is encrypted at rest with AES-256-GCM. The encryption master key lives in the server environment, not in the database, which has two consequences worth internalizing:
Up to 32 secrets per backend. Each value can be up to 16 KB. Names must match [A-Z][A-Z0-9_]{0,63} โ start with an uppercase letter, then up to 63 more uppercase letters, digits, or underscores.
Secrets are managed from the Cloud console โ owner only โ at lingcode.dev/backends.html โ your backend โ Secrets. From there you can add, update, and delete secrets.
One thing to expect: listing shows the names only. Values are never returned once stored โ you can overwrite a secret, but you cannot read it back. If you forget what a value was, set it again rather than trying to recover it.
Under the hood these are owner-authenticated endpoints, in case you script against them:
GET /api/cloud/account/backends/<backend-id>/secrets # list NAMES only
PUT /api/cloud/account/backends/<backend-id>/secrets/<KEY> # add or update
DELETE /api/cloud/account/backends/<backend-id>/secrets/<KEY> # delete
# PUT body:
{ "value": "sk_live_..." }
Setting a secret is only half the story โ the point is to use it. Secrets are read server-side from functions, and never anywhere else. There are three ways that plays out.
Each builtin declares which secret names it needs, then reads them server-side when you invoke it. You set the secret, then call the builtin โ you never pass the key yourself. For example:
stripe-checkout needs STRIPE_SECRET_KEYresend-byo needs RESEND_API_KEYelevenlabs-tts needs ELEVENLABS_API_KEYtwilio-sms needs TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKENSo the end-to-end loop for taking a payment is: set STRIPE_SECRET_KEY in the console, then invoke the stripe-checkout builtin. The builtin reads the secret on the server; your client only ever sees the checkout URL it returns.
If you write your own custom function, the secrets arrive on the handler's context as ctx.secrets:
export default async function handler(input, ctx) {
const key = ctx.secrets.STRIPE_SECRET_KEY;
// call the vendor API with key โฆ
return { ok: true };
}
Same model as the builtins, just hand-written: you set STRIPE_SECRET_KEY in the console, and it shows up as ctx.secrets.STRIPE_SECRET_KEY when the function runs on the server. The value never leaves the sandbox.
The http-fetch builtin can interpolate a secret into request headers or body using the {{SECRET_NAME}} placeholder syntax. That lets you call an arbitrary allow-listed API with an authenticated request โ without writing a custom function at all:
{
"url": "https://api.example.com/v1/things",
"method": "GET",
"headers": {
"Authorization": "Bearer {{EXAMPLE_API_KEY}}"
}
}
The {{EXAMPLE_API_KEY}} placeholder is replaced server-side with the secret's value before the request goes out. The browser sends the request shape, never the key.
The vault protects against two specific threats. It defends against database exfiltration โ the master key isn't in the database, so a stolen dump doesn't hand over your secrets. And it defends against tampering โ the GCM authentication tag means a modified ciphertext fails to decrypt rather than returning a corrupted value.
What it doesn't do in v1: there is a single deployment-wide master key. There is no per-user key derivation, and no built-in key rotation. Treat the vault as the right home for vendor keys โ it is โ and if a key leaks, rotate it at the vendor (issue a new Stripe key, revoke the old one) and update the secret in the console.