Skip to main content

ADR 0059 — The key that signs the git OAuth state has no default

  • Status: accepted
  • Date: 2026-08-08
  • Prior context: ADR 0024 (production compose secrets), ADR 0058 (it was while dismissing an alert about this key that the defect surfaced)

Context​

While closing the security alerts from ADR 0058, one of them (js/insufficient-password-hash, about oauth-state.ts) was dismissed with the argument that there is no password there: the "password" is GIT_OAUTH_STATE_SECRET, a server-side HMAC key, and HMAC-SHA256 is the right primitive for signing an OAuth state.

The argument is correct and still holds. What it did not consider is that a server key with a known default value signs nothing — and that was exactly the case. Both read points did:

process.env.GIT_OAUTH_STATE_SECRET ?? 'dev-oauth-state-secret-change-me'

It is the same failure mode WEB_ORIGIN already had closed in cors-origins.ts: a development default holding equally in production. Here it is worse for two reasons.

The first is that the value is public. It is in the .env.example of an open-source repository. It is not a weak default an attacker would need to guess; it is a published secret.

The second is what the key protects. The state is what prevents the OAuth callback from being forged (see docs/security-surface.md). With the key known, anyone can sign a valid state for a {projectId, userId, provider} of their choosing, and the callback writes — into the project pointed at by that payload — the git token obtained from the provider. It is CSRF on the git-connection flow, with a git credential as the prize.

And the path to this happening was not hypothetical. docker-compose.prod.yml supplied the literal as a fallback:

GIT_OAUTH_STATE_SECRET: ${GIT_OAUTH_STATE_SECRET:-dev-oauth-state-secret-change-me}

Anyone who forgot the variable brought production up signing with the example key, with no warning at all. This detail decides the shape of the fix, and it is the most important point in this ADR: requiring only that the variable "not be empty" would not have caught anything, because on the real error path it WAS set — with the wrong value.

Decision​

Resolving the key moves out of the use cases into a single function in apps/api/src/infrastructure/security/oauth-state-secret.ts, alongside the cors-origins.ts that solved the equivalent problem. In production (NODE_ENV === 'production') it crashes the boot in three situations:

  1. variable missing or only whitespace;
  2. variable equal to the repository's example literal;
  3. variable shorter than 16 characters.

Outside production the default still applies, because docker compose up with no .env still has to work.

The check runs at boot, in main.ts, not on first use. An api that only failed when someone tried to connect git could stay up for weeks accepting state signed with the public key — the error needs to appear at start, where it is loud and reversible.

Why a function, and not the check at every caller​

For the same reason projectScopeRoot() exists (RN-092): there were two copies of the same literal, in different files (start-git-oauth.use-case.ts and handle-git-oauth-callback.use-case.ts). A duplicated check is a check that one day diverges — and diverging here, the callback would reject every legitimate state. There is a test covering specifically that both ends sign and verify with the same key.

Why a length floor​

The docs/reference/configuration.md table has always said, about this variable, "weak = CSRF on the git connection flow". The floor turns the sentence into a check. It is deliberately low: it rejects senha123 and does not second-guess a key generated by any serious means — openssl rand -base64 32 gives 44 characters.

What changed outside the code​

  • docker-compose.prod.yml stops supplying the literal. The line stays, with an empty value, because omitting it would hide that the variable exists.
  • docker/smoke.sh generates its own key and discards it with the stack. A fixed value in the script would be one more public literal — exactly what the check exists to prevent.
  • The README now shows generating the key alongside the command that brings up the production compose.

Consequences​

Deliberate breakage: a production that today comes up without GIT_OAUTH_STATE_SECRET (or with the example one) stops coming up, with a message saying what to generate. This is the goal, not a side effect: that production was already vulnerable, and there was previously no way to know. Applies to the docker compose -f docker/docker-compose.prod.yml documented in the README.

Nothing changes in Kubernetes: deploy/k8s/base/common/externalsecrets.yaml already pulled the variable from a vault.

Left open, and bigger than this ADR: the same pattern applies to AUTH_JWT_SECRET, BRABO_SERVICE_TOKEN, CREDENTIALS_MASTER_KEY, and SECRET_KEY_BASE, all with a development default in the production compose. None of them were touched here — the scope of this change was the OAuth key, and handling the others in passing would hide that they are four independent decisions, each with its own blast radius. It is recorded in docs/explanation/backlog.md.