Secrets & Credentials
How the API handles secrets — the key names that matter, the Zod fail-fast boundary, and the one rule that keeps real values out of git.
A secret is any value that, if it leaked, would let someone act as us — connect to our database, sign our JWTs, send mail from our domain, or read our object storage. The API touches a lot of them. This section is about the discipline around those values: where each one lives, how the app refuses to start without it, how a couple are stored hashed so even we can't read them back, and the one rule that underpins everything — a real secret value never lands in git.
Two kinds of secret
There are two completely different kinds of secret in play, and conflating them causes most of the confusion. Hold them apart in your head:
- Config secrets — values the app reads from the environment to talk to other services (the database, OpenAI, Microsoft). These live in environment variables, validated at boot.
- Product secrets — values users create and the app stores, like an API key a school mints to call us. These never sit in config; they live in the database, and the sensitive ones are stored hashed, never in the clear.
Config secrets are where you'll spend most of your time, so the sub-pages lead with those and come back to the product side.
Document key names, never values
This is the golden rule. Throughout this section you'll see placeholders like <your-secret>. The real values live only in infra/{prod,staging}/secrets.*.yaml (which are not committed to a public repo) and in your own local .env.local. If you ever find yourself about to paste a real secret into a code block, a commit message, a Slack thread, or a doc — stop.
The key-name inventory
Every config secret the app needs is declared in one place: the Zod schema at the top of libs/shared/src/config/env.config.ts. That schema is the authoritative list — if a key isn't there, the app doesn't know about it. The keys group naturally by the service they unlock. Not every key in the schema is sensitive (a PORT or a FRONTEND_URL is just config), but these groups are the ones that genuinely protect something:
| Group | Key names | Protects |
|---|---|---|
| Database | DATABASE_URL | The Postgres connection — full read/write to all tenant data. |
| JWT / auth | JWT_SECRET_KEY, JWT_SALT_ROUNDS | Signing & verifying access/refresh tokens; forge this and you forge logins. |
| Microsoft 365 | MICROSOFT_CLIENT_SECRET, MICROSOFT_CLIENT_ID, MICROSOFT_TENANT_ID, MICROSOFT_WEBHOOK_SECRET | OAuth login and the directory-sync webhook. |
| Cloudflare / R2 | CLOUDFLARE_API_TOKEN, CLOUDFLARE_AI_SEARCH_API_TOKEN, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY | Object storage and the AI search index. |
| OpenAI + AI gateway | OPENAI_API_KEY, AI_GATEWAY_API_KEY | Model access (and the bill behind it). |
| Unipile | UNIPILE_API_KEY, UNIPILE_WEBHOOK_SECRET | The social-messaging integration. |
| Resend | RESEND_API_KEY | Sending transactional email from our domain. |
| Sentry | SENTRY_DSN | Error/event ingestion endpoint. |
| Docs Basic Auth | DOCS_USERNAME, DOCS_PASSWORD | The HTTP Basic gate over /docs outside development. |
The fail-fast boundary
The schema declares each key. Most are z.string() (required); a few are .optional() because the app has a sensible fallback or the feature is off by default.
const envSchema = z.object({
DATABASE_URL: z.string(),
JWT_SECRET_KEY: z.string(),
JWT_SALT_ROUNDS: z.string(),
MICROSOFT_CLIENT_SECRET: z.string(),
MICROSOFT_WEBHOOK_SECRET: z.string(),
R2_SECRET_ACCESS_KEY: z.string(),
CLOUDFLARE_API_TOKEN: z.string(),
OPENAI_API_KEY: z.string(),
AI_GATEWAY_API_KEY: z.string(),
UNIPILE_API_KEY: z.string(),
UNIPILE_WEBHOOK_SECRET: z.string().optional(),
RESEND_API_KEY: z.string(),
SENTRY_DSN: z.string(),
DOCS_USERNAME: z.string(),
DOCS_PASSWORD: z.string(),
// ...the rest of the schema
});A missing secret is a loud, immediate failure
config() calls envSchema.parse(process.env) at startup. If any required key is missing or the wrong type, Zod throws and the wrapper re-raises it as Config validation error: ..., so the process dies on boot instead of limping along and exploding mid-request three hours later. The downstream config() return value then reshapes the flat env into nested groups (auth.jwtSecret, cloudflare.r2SecretAccessKey, microsoft.webhookSecret) so services read them through ConfigService rather than poking at process.env.
Where to go next
Where They Live
The three homes per environment — .env.local, the secrets YAML, and the Railway sync.
Secrets at Rest
The product side — SHA-256 API-key hashing, the Microsoft clientState secret, and the docs gate.
Adding & Rotating
The step-by-step for a new secret, rotation, the never-commit rules, and the gotchas.
Config & Environment
The full ConfigService surface and how the shaped config is consumed.