Secrets and environment variables for serverless functions
Why serverless secrets belong in per-function config injected as env vars — off your code, prompts, and client bundles — plus the auth-vs-config boundary and an honest scope: a config store, not a secrets manager.
Every serverless function that does something useful eventually needs a credential: an OpenAI key, a Stripe secret, a database URL, a webhook signing secret. The interesting question is not whether you have secrets — you do — but where they live and how they reach the code that uses them. Get that boundary wrong and the key ends up in git history, in a model prompt, or in a client bundle, where it is one screenshot away from being someone else’s key. Get it right and secrets stay boring: set once, injected at runtime, never printed.
This post is about the pragmatic, correct default for serverless secrets on Inquir Compute — per-function environment variables injected at invoke time — and, just as importantly, about being honest about the boundaries of that mechanism. It is a config store for your functions, not a standalone enterprise secrets manager, and treating it as the latter will bite you.
Serverless secrets belong in config, not in your code
There are exactly three places a secret must never be:
- In committed files. A key hard-coded in your handler is a key in your git history forever — rewriting history does not un-clone the repo that already pulled it. It is also baked into every build artifact you produce.
- In a model prompt. If you send
"authenticate with sk-..."to an LLM, that string travels to the model provider, gets logged on their side, and often boomerangs back through your own trace and observability tooling. Prompts are the least private surface in an AI application; treat everything you put in one as public. - In a client bundle. Anything a browser downloads is readable.
NEXT_PUBLIC_*-style variables, values inlined during a frontend build, secrets returned in an API response that the client renders — all of these are effectively published.
The alternative is dull in the best way. The secret lives in per-function config, gets injected as an environment variable at runtime, and your handler reads it from process.env. The value never appears in your source tree, never rides along to the browser, and never needs to be in a prompt. This is the whole idea behind environment variables for serverless functions: separate the code (which you version and ship) from the config (which is environment-specific and sensitive), and let the platform join them at the last possible moment — invoke time.
Environment variables for serverless functions, one function at a time
On Inquir Compute, secrets are attached to a single function as its envVars. You set them one of two ways: in the editor under Config → Environment, or over the API. The API shape is deliberately unremarkable:
// Per-function env vars (Config → Environment in the editor, or API):
await api.updateFunction("function-id", {
envVars: {
OPENAI_API_KEY: "sk-...",
},
});
// Values are stored on the function and injected into the container at invoke time
Two properties of this model are worth internalising.
Scope is the function. These are per-function serverless env vars, not a global bag of variables shared across your account. Each function carries its own set. That is a feature, not a limitation — it means the blast radius of any single secret is one function, and it means you reason about “what can this code touch” by looking at one config panel rather than a shared vault.
Injection happens at invoke time, not at deploy time. The values are stored on the function record and pushed into the container only when the function actually runs. Your build output does not contain them; your deploy bundle does not contain them. If ENCRYPTION_KEY is configured on the deployment, the stored values are encrypted at rest (more on that honest caveat later). And because the platform mediates every read, it can do useful things on your behalf — like redacting the value from anything the UI shows you and from logs and traces.
This works identically across the supported runtimes — Node.js 22, Python 3.12, Go 1.22 — because environment variables are the lowest common denominator every language already understands. There is nothing platform-specific to import; process.env, os.environ, and os.Getenv all just work.
Reading serverless env vars in a handler (and what not to do)
Here is a realistic handler: an AI summarizer that reads a prompt from the request, calls a model, and returns the result. The only secret it needs — OPENAI_API_KEY — comes from the function’s env, and the code treats it as read-only and unprintable.
export async function handler(event, context) {
// OPENAI_API_KEY comes from function env — avoid logging it
const payload = typeof event.body === 'string' ? JSON.parse(event.body || '{}') : (event || {});
const { prompt } = payload;
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
});
return {
statusCode: 200,
body: JSON.stringify({ result: completion.choices[0].message.content }),
};
}
Note what the handler does not do. It does not put the key in the messages array. It does not return the key in the response body. It does not log it. The user’s prompt goes to the model; the API key goes to the SDK constructor and nowhere else. That separation — user data in, credential held privately, only derived output returned — is the entire discipline.
The failure modes are common enough to be worth spelling out as an explicit “do not” list. Every line below is a real way teams leak keys:
// DON'T: hard-code the key — it lands in git history and the build artifact
const openai = new OpenAI({ apiKey: "sk-proj-abc123realkey" });
// DON'T: put the secret in the model prompt — it leaks to the provider and its logs
const messages = [{ role: "system", content: "Authenticate with sk-proj-abc123realkey" }];
// DON'T: return it to the caller or ship it in a client bundle
return { statusCode: 200, body: JSON.stringify({ key: process.env.OPENAI_API_KEY }) };
// DON'T: log it — even to debug
console.log("calling openai with", process.env.OPENAI_API_KEY);
The rule of thumb: a secret should enter your function through process.env and leave it only inside an Authorization header to the service it belongs to. Any other exit — a log line, a response field, a prompt string — is a leak. One practical note: outbound network is disabled by default, so to let this handler reach OpenAI you opt the function into egress — a small but real defense-in-depth win.
The auth-vs-config boundary: inbound keys vs outbound secrets
The single most useful distinction in this whole topic is between the key that guards your function and the secrets your function carries. They are both “API keys,” which is exactly why people conflate them, and the conflation causes real bugs.
Inbound auth is about who is allowed to call your function. On Inquir Compute, each gateway route sets its own auth mode: none, api-key (an X-Api-Key header), or bearer. Platform API keys are 32 bytes of base64url, stored only as a SHA-256 hash, and shown to you exactly once at creation. A route can be scoped to specific keys via allowedApiKeyIds, and deploy-scoped keys are rejected at the gateway. This is authentication of callers — the front door.
Outbound config is about what credentials your function presents to other services. Your OPENAI_API_KEY, your Stripe secret, your database password — these are the keys your code carries when it calls out. They live in envVars, injected at runtime, read from process.env.
These two never touch. The X-Api-Key that a client sends to invoke your gateway route is not your OpenAI key and must never be used as one. Your OpenAI key is not something you ever hand to a caller. When you keep the boundary crisp — inbound keys authenticate the people calling you, outbound secrets authenticate you to the services you call — a whole class of “wait, which key is this?” mistakes simply disappears. If you’re building an AI-agent backend, this is also the boundary that lets you gate who can trigger a tool (inbound api-key on the route) separately from what credential the tool uses to do its work (outbound OPENAI_API_KEY in env).
Per-function isolation and automatic redaction
Because Inquir Compute runs one container per function, secrets are naturally isolated. A function’s envVars are visible only inside that function’s container at invoke time. There is no shared global environment that every function inherits, and — importantly, and honestly — there is no cross-function reference mechanism: function B cannot read function A’s secrets by pointing at them. If two functions need the same key, you set it on both — slightly more manual, and the correct trade: isolation by default beats a shared namespace that quietly widens every secret’s blast radius.
On top of isolation, the platform actively works to keep secrets off the surfaces where they tend to leak:
- The client always sees
[redacted]. When you open the config panel, stored secret values are not sent back to your browser as plaintext — you see a redacted placeholder. Re-saving the form with[redacted]left in place preserves the existing value rather than overwriting it with the literal string. In effect the store is write-mostly: you can set a value, but you don’t read it back out through the UI. - Logs and traces are redacted by key and by pattern. Values whose keys look sensitive (
password,secret,token,api-key, and friends) and values matching known secret shapes (like aBearertoken) are scrubbed from stored logs and traces. This is a safety net, not a license to be careless — you should still never log a secret — but it means an accidentalconsole.logof the wrong object is less likely to persist a live credential into your observability data.
Isolation plus redaction is a good baseline. It is not, however, a full secrets-management product, which brings us to the part of this post that matters most.
Honest scope: a config store, not a secrets manager
Here is the honesty guardrail, stated plainly, because it is the difference between using this feature well and being surprised by it later. Inquir Compute’s secrets feature is a per-function environment-variable config store. It is not a standalone enterprise secrets manager. The /features/secrets page says exactly this, and I am not going to soften it.
Concretely, that means:
- Encryption at rest is conditional. Stored
envVarsare encrypted at rest only if anENCRYPTION_KEYis configured on the deployment. On a managed deployment where that key is set, your values are encrypted. If you self-host and do not configureENCRYPTION_KEY, they are not. Do not assume encryption; verify that the key is set. This is the single most load-bearing caveat on the whole feature. - No rotation. There is no built-in credential rotation. If a key is compromised, you rotate it at the provider (OpenAI, Stripe, your database) and update the
envVarsvalue yourself. The platform will not rotate, expire, or lease credentials for you. - No versioning. There is no version history of secret values, no “roll back to the previous value,” no audit log of every change to a specific secret. It is current-state config.
- No cross-function references. As above, functions cannot reference each other’s secrets. There is no shared vault, no secret “references,” no dynamic secrets.
So when should you reach for a dedicated secrets manager (Vault, AWS Secrets Manager, a cloud KMS) instead of, or in front of, per-function env vars? When you genuinely need automatic rotation, dynamic/short-lived credentials, fine-grained per-secret access policies with a full audit trail, or a single source of truth shared across many services and platforms. Those are real requirements for some organisations, and this feature does not pretend to meet them. What per-function env config is excellent at is the overwhelmingly common case: giving one serverless function the handful of API keys it needs, without those keys ever touching your code, your prompts, or your client. For that — for practical secrets management for functions — it is exactly the right amount of machinery.
Patterns to manage API keys for serverless AI-agent tools
AI-agent backends put a specific kind of pressure on secrets, because an “agent tool” is usually a serverless function that calls a third-party API on the model’s behalf — a search tool, a code-runner, a CRM lookup, a payments action. Each tool needs its own credential, and you do not want the model anywhere near those credentials. A few patterns that hold up:
One key per tool, on the tool’s own function. Because secrets are per-function, give each agent tool its own function and its own envVars. The search tool gets the search key; the payments tool gets the Stripe key; neither sees the other’s secret. This maps per-tool secret scope onto per-function isolation, so a bug or prompt-injection in one tool cannot reach another’s credential.
The model chooses the tool, never the key. The LLM decides which tool to call and with what arguments. It never receives, and never needs, the API key that the tool uses internally — the key is read from process.env inside the handler, after the model has already made its decision. Keep the credential strictly downstream of the model’s reasoning. This is the practical way to manage API keys for serverless AI tools: the key is an implementation detail of the tool, not part of the agent’s context.
Gate the tool at the gateway, not with the outbound key. If only certain callers should be allowed to invoke a tool, use route auth (api-key / bearer) on the gateway — the inbound side of the boundary from earlier. Do not try to use the tool’s own outbound credential as an access-control mechanism; that is not what it is for, and it forces you to expose the credential to gate access.
Assume the prompt is public. Everything the agent can read — system prompt, tool descriptions, retrieved context — should be considered public. Secrets live in env, full stop. If you ever feel tempted to “just put the key in the system prompt so the tool can use it,” that is the signal to stop and move it to envVars.
Takeaway
Secrets on a serverless platform are not hard; they are just easy to do carelessly. The durable, correct default is per-function environment variables: set the value once (in Config → Environment or via api.updateFunction), let the platform inject it into the container at invoke time, and read it from process.env in your handler. Keep it out of committed files, model prompts, and client bundles. Keep the two kinds of API key separate — inbound route auth guards who can call you; outbound envVars are what you present to services you call. Lean on per-function isolation and redaction as your safety net.
And stay honest about the scope. This is a per-function config store for serverless env vars, with encryption at rest when ENCRYPTION_KEY is configured and automatic redaction — not a full secrets manager with rotation, versioning, or cross-function references. For the everyday job of getting a few API keys safely into your functions, that is precisely what you want. When you truly need rotation and dynamic credentials, reach for a dedicated secrets manager and know exactly why. Matching the tool to the problem — that is the senior move.