Palbase
Sign inGet started

Backend SDK

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 fetch also passes the outbound fence. If this Environment has an allowlist at all, api.stripe.com has 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 .env file, no palbase secret pull and no palbase secret push. Nothing in the toolchain reads a dotenv file or writes one, and .env, .env.* and *.env are excluded from the deploy tarball unconditionally. The old pull wrote 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

RuleValueWhat you see when you break it
Name shape^[A-Z][A-Z0-9_]*$ — an environment-variable name400 invalid_name
Valuemust be non-empty400 invalid_request — "use DELETE to remove one"
Value size64 KiB413 value_too_large from the vault
Removing what is absentallowed204 — idempotent by design

Warning: PB_NOTIFICATIONS_* is the namespace palbase notifications add writes provider credentials into. Nothing enforces it: palbase secret set validates no names beyond the shape above, and neither the CLI nor the stack checks for the prefix — so palbase secret set PB_NOTIFICATIONS_SENDGRID_API_KEY=… succeeds and overwrites what notifications add wrote. 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.env too. 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.ts left 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 — defineSecrets and secret() 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.