Naalya Handbook
Secrets & Credentials

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:

GroupKey namesProtects
DatabaseDATABASE_URLThe Postgres connection — full read/write to all tenant data.
JWT / authJWT_SECRET_KEY, JWT_SALT_ROUNDSSigning & verifying access/refresh tokens; forge this and you forge logins.
Microsoft 365MICROSOFT_CLIENT_SECRET, MICROSOFT_CLIENT_ID, MICROSOFT_TENANT_ID, MICROSOFT_WEBHOOK_SECRETOAuth login and the directory-sync webhook.
Cloudflare / R2CLOUDFLARE_API_TOKEN, CLOUDFLARE_AI_SEARCH_API_TOKEN, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEYObject storage and the AI search index.
OpenAI + AI gatewayOPENAI_API_KEY, AI_GATEWAY_API_KEYModel access (and the bill behind it).
UnipileUNIPILE_API_KEY, UNIPILE_WEBHOOK_SECRETThe social-messaging integration.
ResendRESEND_API_KEYSending transactional email from our domain.
SentrySENTRY_DSNError/event ingestion endpoint.
Docs Basic AuthDOCS_USERNAME, DOCS_PASSWORDThe 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.

libs/shared/src/config/env.config.ts
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

On this page