Secrets
The Secrets service reads the API keys, provider credentials and signing material your backend needs. There is no .env file to ship and no environment variable to set on a container: a secret is written once into the Environment's vault — with palbase secret set, from Studio, or through the management API — and your deployed code reads it back by name, or finds it already on process.env. Nothing in your source tree declares or restricts that set. What your tree does hold is a derived copy of the names: palbase build reads them off the linked stack and writes palbase-stack.d.ts, so Secrets.get("STRIPE_KEY") takes a name the vault actually holds and a typo is a compile error. It is generated output read FROM the stack, never a second list to keep in step — which is exactly what retired config/secrets.ts.
Quick example
import { Controller, Post, Body, Secrets, PalError, z } from "@palbase/backend";
const ChargeIn = z.object({ amount: z.number().int().positive() });
type ChargeIn = z.infer<typeof ChargeIn>;
// NAMED, because the response schema is read off the return TYPE: an inline
// `{ ok: boolean }` has no name to bind, so a generated client hands the app an
// opaque value where it should have a struct.
const ChargeOut = z.object({ ok: z.boolean() });
type ChargeOut = z.infer<typeof ChargeOut>;
@Controller("/billing")
export class BillingController {
@Post("/charge")
async charge(@Body(ChargeIn) input: ChargeIn): Promise<ChargeOut> {
const key = await Secrets.get("STRIPE_KEY");
if (!key) throw new PalError(500, "not_configured", "STRIPE_KEY is not set for this Environment");
const res = await fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: { Authorization: `Bearer ${key}` },
body: new URLSearchParams({ amount: String(input.amount), currency: "usd" }),
});
return { ok: res.ok };
}
}
Note: That
fetchalso passes the outbound fence. If this Environment has an allowlist at all,api.stripe.comhas to be on it —palbase egress add api.stripe.com, then push. See Outbound Network.
Setting one from the CLI
This is the door almost every reader wants. palbase secret registers three verbs — set, list and remove — and each acts on whatever the checkout is linked to, through /v1/management/secrets on that stack:
palbase secret set SENTRY_DSN --stdin < dsn.txt # the safe form
cat AuthKey_ABC123.p8 | palbase secret set APNS_KEY --stdin
palbase secret set SENTRY_DSN=https://… # lands in your shell history
palbase secret list # names and when they changed — never values
palbase secret remove SENTRY_DSN
--stdin is the only flag that supplies a value, and it reads standard input whole — the trailing newline included, because a PEM without its final newline is a PEM that fails to parse. It reads at most 64 KiB, which is also the vault's own ceiling. Empty input is refused rather than stored: "a secret set to empty is a secret nobody notices is gone".
To hand the values to a process on your own machine without writing them anywhere, use palbase run:
palbase run -- node scripts/backfill.js
It reads one value per name and puts them in the child's environment copy. It never calls os.Setenv, never writes a file, and prints only the count and the names on stderr.
Note: There is no
.envfile, nopalbase secret pulland nopalbase secret push. Nothing in the toolchain reads a dotenv file or writes one, and.env,.env.*and*.envare excluded from the deploy tarball unconditionally. The oldpullwrote every decrypted secret to.env.local, which is how a production credential ends up in a screen share and then in a repository.
Secrets belong to an Environment. Staging's SENTRY_DSN is not production's, and the CLI acts on the environment this checkout resolves to — --env <name> names another, and the banner the command prints says which. See Linking a Checkout.
Name and size rules
| Rule | Value | What you see when you break it |
|---|---|---|
| Name shape | ^[A-Z][A-Z0-9_]*$ — an environment-variable name | 400 invalid_name |
| Value | must be non-empty | 400 invalid_request — "use DELETE to remove one" |
| Value size | 64 KiB | 413 value_too_large from the vault |
| Removing what is absent | allowed | 204 — idempotent by design |
Warning:
PB_NOTIFICATIONS_*is the namespacepalbase notifications addwrites provider credentials into. Nothing enforces it:palbase secret setvalidates no names beyond the shape above, and neither the CLI nor the stack checks for the prefix — sopalbase secret set PB_NOTIFICATIONS_SENDGRID_API_KEY=…succeeds and overwrites whatnotifications addwrote. An SDK doc-comment claims the CLI refuses it. It never has. Leave the namespace alone.
Reading
import { Secrets } from "@palbase/backend";
const key = await Secrets.get("STRIPE_KEY"); // string | null
get is the whole surface. It answers null when this Environment holds no secret under that name — handle it explicitly, because a missing credential is a configuration problem and failing loudly at the first call beats sending an unauthenticated request to a provider.
Reads are cheap. The value is held in the process and single-flighted, so a handler that needs a key on every request pays a map lookup rather than a round trip. A transport failure is never cached, so a briefly unreachable vault does not turn into a null your code remembers.
Also on process.env — with nothing to declare
Every secret in the Environment's backend vault is mirrored into process.env under its own name before your bundle is imported, so a library that reads an environment variable on its own finds it — including at module scope, which is where most of them read it:
// Works without any Palbase-specific call. The vault put it there.
declare class Stripe {
constructor(key: string);
}
const stripe = new Stripe(process.env.STRIPE_KEY!);
You declare nothing for this. Write the secret to the vault and the name is there. The vault's two namespaces already draw the line between your Environment's secrets and the platform's, so a second list of names in your repository would only have been the same boundary written twice.
Three details worth knowing:
- Rotation reaches
process.envtoo. The mirror re-lists the vault whenever the secrets generation moves, so a secret written after boot arrives without a redeploy. Read the variable where you use it rather than freezing it in a module-level constant, or your process keeps the value it booted with. - The mirror only removes what it wrote. A variable set on the container by something else is left alone, even when no vault secret shares its name.
- A vault that is briefly unreachable never empties your environment. The name listing answers with an empty list and logs; the mirror keeps the previous value for a name it could not read this round.
Secrets.get() is still the typed way to read one, and it is the one to use when you need to handle "not configured" explicitly: it answers null, while a missing environment variable is undefined and reads as an empty string in places you would rather it did not.
Warning: A
config/secrets.tsleft over from an older checkout is ignored completely. It is not read at build time, it is not read at deploy time, and the runtime never sees it —defineSecretsandsecret()are still exported and still validate what you type, and nothing reads the result. Declaring a name adds nothing, restricts nothing and gates nothing. Delete the file. See Stack Settings.
What secrets=N in the boot log means
The runtime prints one line per boot:
[runtime] booted: endpoints=37 deploy=57788ca062dc digest=… warm=true secrets=4 sdk=22.1.0 runtime-sdk=22.0.0
secrets=4 is the number of names the Environment's vault holds. It is not a count of anything an artifact declared — no deploy carries declared secret names any more. secrets=0 means the vault is empty.
Rotation takes effect without a deploy
Overwrite a secret and every running instance picks up the new value within about ten seconds. Nothing restarts and nothing redeploys; in-flight requests finish on the value they started with.
The mechanism is a secrets generation stamped onto the Environment's deploy pointer. The runtime already re-reads that pointer on a timer (roughly every ten seconds) to notice new artifacts; a rotation moves the generation without moving the deploy, the cached value is dropped, and the process.env mirror re-lists the vault in the same step.
That window is right for rotation. It is not a revocation control: a leaked key stays usable for those few seconds, so treat rotation at the provider as the real revocation and this as how your backend follows it.
The raw vault API
For automation, the vault's own surface is an authenticated HTTP API on your Environment. It takes a service-role key, and the body is raw bytes rather than a JSON string:
# Set a secret
curl -X PUT "$PALBASE_URL/admin/vault/backend/STRIPE_KEY" \
-H "apikey: $PALBASE_SERVICE_ROLE_KEY" \
--data-binary 'sk_live_...'
# From a file — an APNs .p8, a PEM, a service-account JSON
curl -X PUT "$PALBASE_URL/admin/vault/backend/APNS_KEY" \
-H "apikey: $PALBASE_SERVICE_ROLE_KEY" \
--data-binary @AuthKey_ABC123.p8
# What is set (names only — values are never listed)
curl "$PALBASE_URL/admin/vault/backend" -H "apikey: $PALBASE_SERVICE_ROLE_KEY"
# Delete, permanently
curl -X DELETE "$PALBASE_URL/admin/vault/backend/STRIPE_KEY" \
-H "apikey: $PALBASE_SERVICE_ROLE_KEY"
Values are bytes, not lines: a .p8, a DER certificate, a PEM with newlines in it all round-trip exactly as written, so nothing has to be base64-wrapped on the way in and unwrapped on every read. Over 64 KiB is 413 value_too_large.
Deleting is permanent — there is no bin and no tombstone, because a leaked secret that a delete only hid is a leaked secret. Before removing one, read its metadata to see which modules depend on it:
curl "$PALBASE_URL/admin/vault/backend/STRIPE_KEY" -H "apikey: $PALBASE_SERVICE_ROLE_KEY"
# → {"name":"STRIPE_KEY","exists":true,"consumers":[]}
That endpoint returns metadata only — no route returns a value under /admin/vault/{namespace}/{name}. The one route that does is GET /admin/vault/backend/{name}/value, which answers application/octet-stream with Cache-Control: no-store.
The Environment's Management API is the other door onto the same store, and it is the one the CLI uses: GET /v1/management/secrets (names and updated_at), PUT /v1/management/secrets/{name} with {"value": "…"}, DELETE /v1/management/secrets/{name}, and GET /v1/management/secrets/{name}/value, which returns the value as text/plain and exists so palbase run can fill a child process's environment. Both doors are the same vault; there is no second copy to keep in step.
What you cannot read
Secrets is your Environment's own store. Palbase's platform secrets — the keys the stack signs tokens with, its own provider credentials — live in a separate platform namespace, and no route returns a platform secret's value at all: the modules that need them read them in-process.
This is structural rather than a naming convention. The namespace backend is written into the value route's pattern as a literal, so there is no request that can ask that route for a platform secret; the router has nowhere to send it, and an unknown namespace answers 404 unknown_namespace. The platform's own root key is scrubbed out of the runtime process before your code is imported, so the runtime could not decrypt one even if a route existed. Asking Secrets.get() for one of those names simply returns null.
A management credential that leaks therefore costs the deployed backend's secrets — which rotate — and not the keys the stack signs with, which do not.
Note: If a name you write collides with one of the platform credentials the fence had just scrubbed, the runtime logs it and your value wins:
secrets: <NAME> — declared by this project AND held by the platform. The value your code will read is YOURS, from your vault, not the stack's.
Testing
There is no in-process secrets mock to install. @palbase/backend/test gives you api / createTestApi, an HTTP client that calls the release under test on a real Environment, so a test exercises whatever that Environment's vault actually holds. Seed the name you need with palbase secret set against that Environment; a name you have not set reads as null, which is exactly what production does before the secret is written — so the "forgot to configure it" path is testable. See Testing.
Choosing names
Use the provider's own name for the credential — STRIPE_KEY, TWILIO_AUTH_TOKEN, APNS_KEY. Names are per Environment, so development and production hold different values under the same name and your code never branches on which one it is running in.
Related
- Stack Settings — the one door for every setting, and why
config/is dead - Secrets on the CLI — the platform view of the vault
- Outbound Network — reaching the provider whose key you just stored
- Introduction — the boundary a secret belongs to
- Notifications — provider credentials the CLI writes for you
- Testing — how a test reaches a real release