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:
- variable missing or only whitespace;
- variable equal to the repository's example literal;
- 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.ymlstops supplying the literal. The line stays, with an empty value, because omitting it would hide that the variable exists.docker/smoke.shgenerates 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.