Secret Vault
The secret vault is Foir’s built-in store for plaintext secrets that your project’s apps need at runtime — third-party API keys, signing certificates, push notification credentials, OAuth client secrets. Secrets are stored encrypted, addressed by an opaque reference, and accessed through scoped API calls that record an audit trail of every read.
When to use the vault
If you’re already using Foir as your data and content layer, the vault is the natural home for any secret your apps need. It sits alongside the rest of the platform — same project boundary, same admin UI, same CLI, same audit surface — so secrets are managed in the same place as the records and operations they support, rather than in a separate service you have to wire up and pay for.
The vault is not trying to be a general-purpose enterprise secret store. It doesn’t replace AWS Secrets Manager, Azure Key Vault, or HashiCorp Vault for organisations that need cross-cloud federation, dynamic secret generation, PKI / certificate issuance, KMS integration, or database credential leasing. Those are real features Foir doesn’t ship. But for the typical case — “my Foir-integrated app needs to call a third-party API at runtime and I’d rather not run a separate secret service for it” — the vault is the right answer.
The one secret that has to live outside the vault is the API key your app uses to call Foir in the first place — that’s a chicken-and-egg situation, and it lives in your deployment platform’s env-var system (wrangler secrets, Vercel env vars, Railway variables, etc.).
A simple rule of thumb:
| Secret shape | Right home |
|---|---|
| The API key your app uses to talk to Foir | env vars / wrangler / deploy platform |
| Any other runtime secret your Foir-integrated app needs | Foir vault |
| Secrets for workloads that aren’t on Foir at all | external (AWS Secrets Manager, Vault Cloud, etc.) |
What’s stored
Each secret in the vault is keyed by:
- Owner kind —
project(project-wide secret) orapp(scoped to a specific app installation) - Owner ID — the app name when the owner kind is
app; empty for project secrets - Label — a human-readable handle you choose (e.g.
apns_p8_prod,deepl_api_key)
Together these uniquely identify the secret within a project. Reads return the plaintext bytes. The vault never exposes plaintext through normal record reads — it’s only available through the dedicated getSecret field, gated by an explicit scope.
Reading secrets
Two callers can read secrets through Foir’s public API:
Direct: sk_ API keys
A backend service that holds an sk_ (secret) API key with the secrets:read.project scope can call:
query {
getSecret(ref: "sec_abc123...", purpose: "deepl_translation") {
plaintextBase64
label
lastWrittenAt
}
}The ref is the opaque handle returned when the secret was created. The purpose is required and recorded in the audit trail — describe why the secret is being read (e.g. "deepl_translation", "apns_jwt_mint", "shopify_admin_api_call"). plaintextBase64 is the secret’s bytes encoded with standard base64; decode in the receiving language.
secrets:read.project is restricted to sk_ keys only — pk_ (publishable) keys cannot hold the scope, because those keys are designed to ship in browser bundles where plaintext exposure would be unsafe.
Operations: dispatched receivers
When Foir dispatches an operation to your app’s endpoint, the request carries a scoped token (X-Foir-Token) that already grants secrets:read.app (for app-scoped operations) or secrets:read.project (for project-scoped ones). The receiver calls the same getSecret field, authenticated with the dispatch token instead of an API key:
// inside an operation handler
const ref = lookupSecretRefForThisDispatch(); // your code
const resp = await fetch(`${FOIR_API_URL}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Foir-Token': dispatchToken, // received with the operation
},
body: JSON.stringify({
query: `query($ref: String!, $purpose: String!) {
getSecret(ref: $ref, purpose: $purpose) {
plaintextBase64
}
}`,
variables: { ref, purpose: 'apns_jwt_mint' },
}),
});The dispatch token is bound to the project the operation was triggered for. The vault enforces this: a token bound to one project cannot read another project’s secrets, so multi-tenant apps that serve many Foir projects automatically read the right tenant’s secrets without extra plumbing.
Storing secrets
From the admin UI
The fastest way for one-off secrets is Settings > Secrets in the admin app. Production secrets are typically managed here — operators paste plaintext directly, and the vault handles encryption.
From the CLI
For repeatable workflows, use foir secrets:
foir secrets put --label deepl_api_key --value "$DEEPL_KEY"
foir secrets put --app shopify-app --label apns_p8_prod --file ./secrets/apns-prod.p8
foir secrets list
foir secrets rotate <ref> --file ./secrets/apns-prod-new.p8
foir secrets delete <ref>Config-as-code: foir.secrets.ts
Apps with several secrets can declare them in a committed config and reconcile them per-environment. The shape (no plaintext) lives in foir.secrets.ts:
import { defineSecrets } from '@eide/foir-cli/configs';
export default defineSecrets({
secrets: [
{ ownerKind: 'project', label: 'deepl_api_key' },
{ ownerKind: 'app', ownerId: 'shopify-app', label: 'apns_p8_dev' },
{ ownerKind: 'app', ownerId: 'shopify-app', label: 'apns_p8_prod' },
{ ownerKind: 'app', ownerId: 'shopify-app', label: 'fcm_sa_prod' },
],
});Plaintext for development lives in a sibling file kept out of source control:
// local.foir.secrets.ts (gitignored)
import { readFileSync } from 'node:fs';
export default {
deepl_api_key: process.env.DEEPL_API_KEY!,
apns_p8_dev: readFileSync('./secrets/apns-dev.p8'),
apns_p8_prod: readFileSync('./secrets/apns-prod.p8'),
fcm_sa_prod: readFileSync('./secrets/fcm-sa-prod.json', 'utf-8'),
};Run the reconciler against the project the CLI is currently scoped to:
foir secrets push # create missing, leave existing alone
foir secrets push --rotate # rotate plaintext on every declared secret
foir secrets push --dry-run # preview without writingProduction secrets are not pushed via CLI in normal workflows — operators set them through the admin UI so plaintext never lives in any developer’s working tree. The reconciler is a dev-loop convenience.
Rotation
foir secrets rotate <ref> (or foir secrets push --rotate) replaces the plaintext for an existing reference. Existing readers continue to work without any deploy — the next call to getSecret returns the new plaintext. There’s no need to redeploy the consuming app, restart workers, or re-issue API keys.
Audit
Every read records:
- Who called (the API key, dispatch token, or admin user)
- The
purposestring the caller supplied - When the read happened
Reads from API keys and operation receivers are distinguishable from admin reads, and from each other, in the audit log.
Choosing labels
Labels are the part you’ll touch most. A few rules of thumb:
- Stable. Labels live in your
foir.secrets.tsand in your consumer code; treat them like enum values, not user-editable strings. - Self-describing.
apns_p8_prodreads better thansecret1six months later. - Encode the variant. If the same secret kind exists per environment or per build target, encode that in the label (
apns_p8_dev/apns_p8_prod) so consumers can resolve the right one from the dispatch payload.
What’s not in the vault
- Customer-facing OAuth tokens managed by Foir’s customer auth flows — those are handled separately by Foir on behalf of authenticated customers and don’t appear in the vault.
- Bootstrap secrets like the API key your app uses to talk to Foir in the first place — those have to live in your deployment platform’s env-var system to break the chicken-and-egg problem.
- Per-record encrypted fields. If you want some fields of a record to be encrypted at rest, that’s a record-level concern, not a vault one.