Skip to main content

Auth configuration — GarraIA Gateway

Reference for the [auth] config block and the environment variables consumed by garraia-config::AuthConfig + garraia-gateway::mobile_auth. Delivered as plan 0046 (GAR-379 slice 3) on 2026-04-22 (America/New_York).

This page consolidates the precedence rules between environment variables, the [auth] section of the config file, and the built-in defaults. Every secret is environment-only: storing JWT / HMAC / metrics-token material in the config file is intentionally vedado — commit-safe files cannot carry signing key material. Only operational, non-secret knobs live in [auth].

TL;DR for operators​

  1. Set GARRAIA_JWT_SECRET (≥32 bytes, hex/base64 both fine). Generate with openssl rand -hex 32.
  2. Set GARRAIA_REFRESH_HMAC_SECRET (≥32 bytes, distinct from the JWT secret). Generate with openssl rand -hex 32.
  3. Optional but recommended: pin JWT access TTL and refresh TTL in the [auth] section of your config file (see §2).
  4. Never add jwt_secret = "...", refresh_hmac_secret = "...", or metrics_token = "..." to config.yml / config.toml. They are not parsed and would be a committable secret leak.

1. Precedence matrix​

FieldEnvironment variableConfig file keyDefaultFail mode when missing
jwt_secretGARRAIA_JWT_SECRET (preferred)
GarraIA_VAULT_PASSPHRASE (legacy fallback, deprecated)
GARRAIA_VAULT_PASSPHRASE (last fallback — issue #824)
❌ not accepted❌503 Service Unavailable from /auth/* and /v1/auth/*
refresh_hmac_secretGARRAIA_REFRESH_HMAC_SECRET❌ not accepted❌503 from /v1/auth/* refresh flow
login_database_urlGARRAIA_LOGIN_DATABASE_URL❌ not accepted❌503 (fail-soft)
signup_database_urlGARRAIA_SIGNUP_DATABASE_URL❌ not accepted❌503 (fail-soft)
app_database_urlGARRAIA_APP_DATABASE_URL (optional)❌ not accepted—/v1/groups-style → 503; /v1/me still works
metrics_tokenGARRAIA_METRICS_TOKEN❌ not accepted—Dedicated /metrics listener fails closed; embedded route → 503 for non-loopback
metrics_allow_cidrsGARRAIA_METRICS_ALLOW (comma-separated CIDRs)❌ not accepted—Allowlist miss → 403
jwt_algorithm(none)[auth] jwt_algorithm = "HS256""HS256"config check Error
access_token_ttl_secs(none)[auth] access_token_ttl_secs = 900900 (15 min)config check Error if outside [60, 86400]
refresh_token_ttl_secs(none)[auth] refresh_token_ttl_secs = 604800604800 (7 days)config check Error if outside [60, 2_592_000] or < access_token_ttl_secs
metrics_token_ttl_hint_secs(none)[auth] metrics_token_ttl_hint_secs = 00 (indefinite)Documentation-only

Secrets are env-only by design (plan 0046 §5.1). The loader rejects attempts to smuggle secrets via the file by simply ignoring any unknown key inside [auth] — a misspelled jwt_secret = "..." is parsed as a no-op, not as an override.


2. [auth] section — YAML / TOML​

YAML (config.yml)​

auth:
jwt_algorithm: "HS256" # only HS256 is accepted today
access_token_ttl_secs: 900 # 15 minutes
refresh_token_ttl_secs: 604800 # 7 days
metrics_token_ttl_hint_secs: 0 # 0 = indefinite (rotation hint)

TOML (config.toml)​

[auth]
jwt_algorithm = "HS256"
access_token_ttl_secs = 900
refresh_token_ttl_secs = 604800
metrics_token_ttl_hint_secs = 0

Both forms are equivalent. The file is never required — when absent the defaults above apply automatically (all four fields are #[serde(default)]).

File permissions are Unix-only. config.yml can also carry llm.*.api_key and gateway.api_key; on Unix the writer clamps the mode to 0600 (and creates it 0600 from birth, see write_atomic_secret). On Windows no equivalent hardening exists today — the file inherits the default ACL of its directory, which lets any process of the same user (and administrators) read it. The gap is tracked in #1253; on Windows, prefer env-only secrets (GARRAIA_JWT_SECRET, provider keys via environment or the credential vault) over an on-disk config.yml.


3. Environment variables​

All secrets are consumed exclusively via std::env::var inside crates/garraia-config/src/auth.rs::AuthConfig::from_env. The gateway never reads these variables elsewhere (plan 0046 §4 grep invariants).

3.1 GARRAIA_JWT_SECRET (preferred)​

Signing key for the HS256 JWT access token issued by /auth/register, /auth/login, /v1/auth/login, /v1/auth/refresh, and the OAuth callback. Must be ≥32 bytes after UTF-8 decoding.

# Generate a 64-char hex secret (32 bytes of entropy):
openssl rand -hex 32

3.2 GarraIA_VAULT_PASSPHRASE (legacy fallback, deprecated spelling)​

Accepted when GARRAIA_JWT_SECRET is unset. Preserved for zero-breaking change in dev workflows that predate GAR-379. New deployments should prefer GARRAIA_JWT_SECRET. garraia config check emits a deprecation warning whenever this mixed-case spelling is present.

3.2.1 GARRAIA_VAULT_PASSPHRASE (last fallback — issue #824)​

The canonical credential-vault passphrase (all-caps) doubles as the last JWT-secret fallback. Before #824, exporting only this spelling left /auth/* answering 503 while the near-identical mixed-case alias above would have worked — a silent casing trap. The reverse also holds now: the credential vault (garraia-security) accepts the mixed-case alias as a fallback, with a deprecation warning at boot.

Precedence: GARRAIA_JWT_SECRET > GarraIA_VAULT_PASSPHRASE > GARRAIA_VAULT_PASSPHRASE. The mixed-case alias stays ahead of the all-caps spelling so deploys that set both with different values keep signing JWTs with the same secret as before #824. Covered by unit tests from_env_prefers_jwt_secret_over_vault_passphrase, from_env_accepts_canonical_vault_passphrase_as_last_fallback and from_env_prefers_legacy_alias_over_canonical_vault_passphrase.

3.3 GARRAIA_REFRESH_HMAC_SECRET​

HMAC-SHA256 key used by garraia-auth::SessionStore to hash opaque refresh tokens. Must be distinct from GARRAIA_JWT_SECRET (the reuse would turn a leaked JWT into a refresh forgery). ≥32 bytes.

3.4 GARRAIA_LOGIN_DATABASE_URL / GARRAIA_SIGNUP_DATABASE_URL​

Postgres connection URLs for the garraia_login and garraia_signup BYPASSRLS roles. Both required for the /v1/auth/* flow. See ADR 0005 for the rationale.

3.5 GARRAIA_APP_DATABASE_URL (optional)​

Postgres URL for the garraia_app RLS-enforced role. When absent, only /v1/groups-style write endpoints are disabled; the rest of /v1/auth/* and /v1/me continue to work.

3.6 GARRAIA_PURGE_DATABASE_URL (optional)​

Postgres URL for the garraia_purge role (migration 034) — the EXECUTE-only credentials the account-purge worker uses to run the four SECURITY DEFINER deletion functions. When absent the gateway still boots and DELETE /v1/me still enqueues requests; only the automated worker is skipped (explicit warn in the log) and the manual procedure in docs/legal/data-subject-requests.md applies. Never reuse the app/login/signup DSNs here — the separation is what keeps purge capability out of request-reachable credentials.

3.7 GARRAIA_METRICS_TOKEN / GARRAIA_METRICS_ALLOW​

Bearer token and CIDR allowlist for the /metrics endpoint. Loaded from garraia-telemetry::TelemetryConfig::from_env → wired through MetricsAuthConfig::from_telemetry_raw. See docs/telemetry.md for the full plan 0024 behavior.


4. Fail modes (plan 0046 §5.2)​

When AuthConfig::from_env returns Ok(None) (any required env var missing), the gateway boots in fail-soft mode: the main listener comes up, non-auth routes serve normally, and auth routes respond 503 Service Unavailable with a stable JSON body:

{"error": "auth not configured"}

This behavior applies to:

  • POST /auth/register (mobile legacy)
  • POST /auth/login (mobile legacy)
  • GET /me (mobile legacy — extractor fails closed)
  • POST /v1/auth/login
  • POST /v1/auth/signup
  • POST /v1/auth/refresh
  • POST /v1/auth/logout
  • OAuth callback (/oauth/{provider}/callback)

Zero hardcoded fallback. Prior to plan 0046 the legacy mobile flow used "garraia-insecure-default-jwt-secret-change-me" as a dev fallback — that string is gone from the codebase. Tokens signed with it will fail verification immediately on upgrade.

The legacy /auth/* surface is gated by the same wiring — there is no SQLite-only auth mode. mobile_auth reaches the signing secret exclusively through AppState::jwt_signing_secret(), and that field is populated only after both Postgres pools (garraia_login + garraia_signup) connect and pass their role guard (server.rs, all-Ok arm). Consequences:

  • Setting only GARRAIA_JWT_SECRET unlocks nothing — /auth/* and /v1/auth/* keep answering 503.
  • A reachable Postgres with the wrong role in the DSN (e.g. a superuser URL) fails the SELECT current_user guard, the gateway logs garraia-auth wiring partially failed; /v1/auth/* will return 503, and the legacy /auth/* stays down with it.
  • Dev deployments that don't want Postgres must simply accept the 503s as the (correct, fail-closed) steady state.

4.1 Minimal local auth — is the 503 a problem?​

Almost always: no. garraia doctor and garraia config check warn that no JWT secret is set, and issue #925 was filed because the warning did not say whether it mattered. It usually does not.

What keeps working with no auth secret at all:

SurfaceWhy it is unaffected
Web Console (/, webchat.html)the /api/* endpoints never use a JWT — see the note below on the gateway API key
WebSocket chat (/ws)authenticates with the gateway API key, not a JWT
POST /v1/chat/completions, POST /v1/messagescompat surfaces (OpenAI/Anthropic): no JWT in the path, they sit on the gateway API-key axis instead (#1240)
POST /a2a/tasks and the rest of /a2a/*same axis — the A2A surface never used a JWT
garra mcp-server (stdio)in-process, no HTTP auth in the path at all
garra chat / garra asklocal CLI, talks to the runtime directly
Telegram / Discord / Slack / WhatsAppchannel adapters carry their own allowlist

What is actually down:

SurfaceSymptom
/auth/*, /v1/auth/*503 {"error": "auth not configured"}
POST /chat, GET /chat/history (mobile app)MobileAuth resolves the secret through AppState::jwt_signing_secret(), so these fail closed too
TOTP enrolment/verificationsame extractor
The multi-tenant workspace (/v1/groups, /v1/me, …)needs a real identity

So the rule of thumb: a single-user gateway on 127.0.0.1, driven from the CLI, the web console, an MCP host or a chat channel, never touches the auth stack. Leaving the warning standing is a legitimate steady state — it is fail-closed, not degraded.

/api/* and the gateway API key — a different axis​

None of the above is about gateway.api_key, which is a separate control and answers a different question. The JWT stack is who are you; the gateway key is are you allowed on this port at all.

Since #1045, a configured gateway.api_key is required on the whole /api/* REST surface, not just on the /ws handshake as before. Since #1240 it also covers the conversation plane and the A2A surface, which are mounted on the same raw router and had been left out:

PathMethodGated since
/api/*any#1045
/v1/chat/completionsPOST#1240
/v1/messages, /v1/messages/count_tokensPOST#1240
/a2a/* (whole namespace, by prefix)any#1240
/ws, /ws/parrot (handshake)GET#1045 / #1240

These were the reason the gate had to stop being a single /api/ prefix test. The original exclusion note said "/v1/* has its own JWT" — true of the rest_v1 workspace routes (/v1/me, /v1/groups) and of /v1/auth/*, and not true of the OpenAI/Anthropic compat routes, which only share the prefix and resolve no identity at all. Those routes run GarraIA's tools on the operator's machine, so a key that did not cover them was a control the operator had switched on believing it closed the port.

Open with the key configured, because they are how a client finds the gateway before it has one, and none of them executes anything:

  • /api/health, /api/capabilities, /api/auth-check (all secret-free);
  • /v1/models and /.well-known/agent.json (discovery);
  • /health, /ping.

/v1/auth/*, /v1/me and the rest of rest_v1 are not on this axis: they answer with their own JWT/503 contract, gate or no gate.

With no gateway.api_key set, nothing changes — every one of the routes above answers exactly as it always did, which is the right default for a gateway bound to 127.0.0.1. #1240 closed nothing by default; it only made the key cover what the operator already thought it covered. It is binding to 0.0.0.0 that makes the key worth setting, and that is the case the mobile app on the LAN puts you in. See docs/hardening-gateway.md §2.

The key travels in the Authorization: Bearer header. The two Anthropic compat routes (/v1/messages, /v1/messages/count_tokens) also accept it in x-api-key, because Claude Code and the official Anthropic SDK never send a bearer — the two headers are alternatives, and either one being right is enough. Only the two WebSocket handshakes also accept the key in the query string, because a browser's WebSocket handshake cannot carry a header; on REST it would end up in access logs and in the request span.

The two WebSocket handshakes​

/ws (web chat) and /ws/parrot (the Garra Desktop overlay) are checked inside their handlers, not by the /api/* middleware, and both accept the key as ?token= or ?api_key= as well as Authorization: Bearer. The query string is not a convenience here, it is the only channel that works: the Tauri webview opens the overlay with new WebSocket(...), which cannot set a header at all, so gating /ws/parrot in the middleware — which only reads headers — would lock the desktop out instead of authenticating it.

/ws/parrot was gated in the R4 audit of PR #1251, not in #1240 itself. It had been guarded only by the #1182 origin check, which passes by design when there is no Origin header (a non-browser client — app, CLI, curl — never sends one). With gateway.api_key set and the gateway on 0.0.0.0, a websocat ws://host:3888/ws/parrot therefore connected with no credential and drove a full agent turn, with the tools and the operator's LLM key, on the desktop's persistent session. Its sibling /ws, mounted on the line above in router.rs, had checked the key since #1045.

The key is read once at startup, so changing it on disk needs a gateway restart — the config hot-reload does not reach it.

If you do want auth on​

The one trap worth stating plainly: setting the JWT secret alone unlocks nothing. AuthConfig::from_env is all-or-nothing over four variables, and the signing secret is only wired after both Postgres pools connect and pass their role guard (§4 above). A half-configured deployment answers 503 exactly like an unconfigured one — which is why config check has a separate "all-or-nothing" warning that lists what is still missing.

The full path is §7 (Provisioning the Postgres roles). The secrets themselves are generated the same way as everywhere else in this document:

export GARRAIA_JWT_SECRET=$(openssl rand -hex 32)
export GARRAIA_REFRESH_HMAC_SECRET=$(openssl rand -hex 32)

Persist them the way your service manager does — a systemd EnvironmentFile, a .env your shell profile sources, or your container runtime's secret store. Never commit them, and note that garraia config check only ever reports their presence, never their value.

The vault-passphrase confusion​

garraia init prints an export line for the vault passphrase when you choose to store provider API keys in the encrypted vault. That variable is for the credential vault — but it is also the last fallback for the JWT signing secret (§3.2.1, issue #824). Two consequences worth knowing:

  • If you ran the wizard and chose the vault, you may already have a JWT secret without realising it, and the "none of …" warning will not fire.
  • Reusing one passphrase for both is not recommended beyond a single-user local install: rotating the vault passphrase then invalidates every issued token. Set the JWT secret explicitly to decouple them.

5. config check integration​

garraia config check (plan 0035 / GAR-379 slice 1) validates the [auth] block and cross-checks it against the process environment:

  • Error when jwt_algorithm is not in the accepted set (HS256).
  • Error when access_token_ttl_secs is outside [60, 86400].
  • Error when refresh_token_ttl_secs is outside [60, 2_592_000] or smaller than access_token_ttl_secs.
  • Warning when none of GARRAIA_JWT_SECRET, GarraIA_VAULT_PASSPHRASE or GARRAIA_VAULT_PASSPHRASE is set (auth flow will 503). Since issue #925 the message also says which surfaces stay up, which go down with it, and that this one variable is not enough on its own — see §4.1. The "none of GARRAIA_JWT_SECRET" prefix is asserted on by tests in check.rs; extend the message, never rewrite its opening.
  • Warning when a JWT secret env var is set but any of GARRAIA_REFRESH_HMAC_SECRET, GARRAIA_LOGIN_DATABASE_URL, GARRAIA_SIGNUP_DATABASE_URL is missing — AuthConfig::from_env is all-or-nothing, so /auth/* and /v1/auth/* still answer 503 (previously this partial state passed config check clean).
  • Error (network section, field gateway.host) when the resolved bind (see §5.1) is not loopback and no gateway credential is set (gateway.api_key or GARRAIA_GATEWAY_API_KEY): garraia start refuses that bind (#1261), so config check never reports it clean. With a credential but no TLS the finding is a Warning; with the file-only opt-out gateway.allow_unauthenticated_network_bind: true it is a Warning, and the opt-out itself is always reported.
  • Warning (field gateway.api_key) when GARRAIA_GATEWAY_API_KEY and gateway.api_key are both set and differ — the env var wins. Presence only; values are never emitted.
  • Warning when the env secret is set and [auth] overrides are present — non-secret overrides apply but secrets remain env-only.
  • Warning (deprecation) whenever the mixed-case GarraIA_VAULT_PASSPHRASE spelling is present (issue #824).
  • Warning when both passphrase spellings are set with different values — the vault prefers the all-caps one while the JWT fallback prefers the mixed-case one (presence/equality only; values are never emitted).

The JSON output of config check --json never contains secret values — only presence flags (plan 0035 SEC-M-02).

5.1 The gateway bind address — what config check sees vs what start binds​

The host and port the gateway actually binds come from the command line, in this order:

  1. explicit --host / --port flags, then
  2. the HOST / PORT environment variables (clap env = "..." — how RunPod / container runtimes inject theirs, GAR-603), then
  3. the clap built-in defaults 127.0.0.1 : 3888.

The gateway.host / gateway.port keys in config.yml / config.toml do not feed garra start / garra restart. Commands::Start overwrites config.gateway.host / config.gateway.port unconditionally right after ConfigLoader::load() (crates/garraia-cli/src/main.rs), and because both clap args always carry a value (flag, env, or built-in default), the file value never survives. Measured on v0.4.2: a config.yml with gateway.host: 0.0.0.0 and port: 3977 still binds 127.0.0.1:3888, while HOST=… changes the banner and the bind.

garraia restart reads HOST / PORT exactly like garraia start (#1261 — before, restarting a RunPod/HOST daemon quietly rebound it to loopback).

gateway.host / gateway.port in the file are deprecated (#1261, decision B). They still parse, for back-compat, but nothing reads them to bind: garraia init no longer writes them and removes them on a re-run, the Web Console shows the running bind read-only, and the client commands (garraia status, stop, doctor, admin) target the same address start uses (HOST/PORT, else 127.0.0.1:3888, with 0.0.0.0/:: mapped to loopback) instead of the file value. Removing the keys from the schema is deliberately not done in v0.4.5: once nothing reads or writes them, deleting them would only turn a helpful deprecation warning into a silent no-op.

The boot refuses an exposed bind without a credential​

Since v0.4.5 (#1261, decision A), garraia start, start -d and restart refuse to boot (exit 78, EX_CONFIG) when any address the bind resolves to is not loopback and no gateway credential is configured. The refusal happens before the socket is bound, before the fork in -d mode (so the message lands on the terminal), and before restart stops the running daemon. TLS does not exempt the bind: TLS without a credential is still open to anyone who can reach the port. A hostname counts as exposed if any of its resolved addresses is not loopback; a hostname that does not resolve is refused too.

Fix it with one of:

  • run garraia init (it writes gateway.api_key on a server-like machine or when HOST is not loopback);
  • set gateway.api_key in the config file;
  • export GARRAIA_GATEWAY_API_KEY=<a long random secret> — the env var wins over the file and is never written back to disk (the way to give a container a key without editing a read-only mounted config);
  • or bind locally: garraia start --host 127.0.0.1 (unset HOST).

A deployment that is open on purpose, behind an authenticating proxy or a firewall, can set gateway.allow_unauthenticated_network_bind: true in the config file. It is file-only by design — there is no env var or flag, so the same HOST injection that exposes the bind cannot also switch the guard off — and every boot with it logs a loud warning.

Consequences for config check:

  • garraia config check and garraia doctor resolve the bind the way start does, minus the flag: HOST / PORT from their own environment if set, otherwise 127.0.0.1 : 3888. A non-loopback IP literal without a credential is an Error ("garraia start will REFUSE"); a hostname other than localhost gets a warning saying the check cannot judge it (start resolves it and applies the rule).
  • The file's gateway.host / gateway.port are reported by a separate deprecation warning ("deprecated and not read by garraia start") when they differ from the resolved bind. Under --strict that warning exits 2: remove the keys.
  • What the check cannot see is a --host / --port flag on a later start/restart (another process). That gap is closed at runtime: the boot applies the same rule to the real bind.
  • The dedicated /metrics listener follows the same precedent: it refuses to start on a non-loopback bind with no auth configured (metrics_exporter.rs).

6. Troubleshooting​

/auth/login returns 503 "auth not configured"​

Cause: the auth wiring never came up. That takes all four required env vars (GARRAIA_JWT_SECRET or a vault-passphrase fallback, GARRAIA_REFRESH_HMAC_SECRET, GARRAIA_LOGIN_DATABASE_URL, GARRAIA_SIGNUP_DATABASE_URL) and both Postgres pools connecting as their exact roles (§4, §7). Start with the secrets:

export GARRAIA_JWT_SECRET=$(openssl rand -hex 32)
export GARRAIA_REFRESH_HMAC_SECRET=$(openssl rand -hex 32)

then provision the database per §7. Run garraia config check to confirm which variables the gateway sees, and look for garraia-auth wired (login + signup pools + jwt) in the boot log.

/v1/auth/login still returns 503 after setting GARRAIA_JWT_SECRET​

Cause: one of the other required env vars is missing (GARRAIA_REFRESH_HMAC_SECRET, GARRAIA_LOGIN_DATABASE_URL, GARRAIA_SIGNUP_DATABASE_URL). AuthConfig::from_env is all-or-nothing for these four. config check lists which ones are detected.

Tokens issued before the upgrade suddenly fail with "invalid or expired token"​

Cause: those tokens were signed with the old garraia-insecure-default-jwt-secret-change-me fallback. They are no longer verifiable. Users must re-authenticate.

config check reports a warning about [auth] overrides​

Cause: operator set both env secrets and custom non-secret fields in [auth]. This is not an error — it's a reminder that the secrets always come from env regardless of the file.


7. Provisioning the Postgres roles​

End-to-end runbook to take a gateway from "all auth vars absent" to a working /v1/auth/* + /auth/*. Until now this sequence only existed in the test harness (crates/garraia-auth/tests/common/harness.rs) and as prose in ADR 0005 §Production.

7.1 Generate the secrets​

export GARRAIA_JWT_SECRET=$(openssl rand -hex 32) # ≥32 bytes
export GARRAIA_REFRESH_HMAC_SECRET=$(openssl rand -hex 32) # distinct from the JWT secret
export GARRAIA_UPLOAD_HMAC_SECRET=$(openssl rand -hex 32) # tus commit integrity (optional in dev)
export GARRAIA_VAULT_PASSPHRASE=$(openssl rand -hex 32) # credential vault
LOGIN_PW=$(openssl rand -hex 24); SIGNUP_PW=$(openssl rand -hex 24); APP_PW=$(openssl rand -hex 24); PURGE_PW=$(openssl rand -hex 24)

Hex output is deliberate: it is ≥32 ASCII bytes and safe to embed in a DSN without URL-encoding. scripts/gen-auth-secrets.sh prints this whole block ready to paste. See also the top-level .env.example.

7.2 Postgres with pgvector​

The migrations require the pgcrypto, citext and vector extensions (superuser to create). Plain postgres:16-alpine does not ship pgvector and fails migration 005 — use the pgvector image:

docker run -d --name garraia-pg -p 127.0.0.1:5432:5432 \
-e POSTGRES_PASSWORD=<superuser-pw> -e POSTGRES_DB=garraia_workspace \
-v garraia_pg:/var/lib/postgresql/data pgvector/pgvector:pg16

7.3 Run the workspace migrations​

There is no garra db migrate; run them out-of-band as a privileged role (sqlx-cli, or psql -f on each file in lexicographic order):

cargo install sqlx-cli --no-default-features --features postgres,rustls
DATABASE_URL=postgres://postgres:<superuser-pw>@127.0.0.1:5432/garraia_workspace \
sqlx migrate run --source crates/garraia-workspace/migrations

7.4 Promote the NOLOGIN roles​

The migrations create garraia_login (BYPASSRLS, migration 008), garraia_signup (BYPASSRLS, migration 010), garraia_app (RLS-enforced, migration 007) and garraia_purge (EXECUTE-only on the four account-purge functions, not BYPASSRLS, migration 034) as NOLOGIN. Promote each with its own password from your secret store (ADR 0005 §Production; pg_hba.conf must use scram-sha-256, never trust):

ALTER ROLE garraia_login WITH LOGIN PASSWORD '<LOGIN_PW>';
ALTER ROLE garraia_signup WITH LOGIN PASSWORD '<SIGNUP_PW>';
ALTER ROLE garraia_app WITH LOGIN PASSWORD '<APP_PW>';
ALTER ROLE garraia_purge WITH LOGIN PASSWORD '<PURGE_PW>';

The purge role is optional at install time: without GARRAIA_PURGE_DATABASE_URL (§3.6) the gateway runs and DELETE /v1/me still enqueues, but no automated purge executes.

7.5 Build the DSNs​

Each pool runs SELECT current_user at connect time and refuses any other role — the DSN must authenticate as the role itself (a superuser URL fails with WrongRole; SET ROLE is not supported):

export GARRAIA_LOGIN_DATABASE_URL="postgres://garraia_login:${LOGIN_PW}@127.0.0.1:5432/garraia_workspace"
export GARRAIA_SIGNUP_DATABASE_URL="postgres://garraia_signup:${SIGNUP_PW}@127.0.0.1:5432/garraia_workspace"
export GARRAIA_APP_DATABASE_URL="postgres://garraia_app:${APP_PW}@127.0.0.1:5432/garraia_workspace"
export GARRAIA_PURGE_DATABASE_URL="postgres://garraia_purge:${PURGE_PW}@127.0.0.1:5432/garraia_workspace" # optional (§3.6)

7.6 Persist and verify​

For the systemd unit, write the variables to /etc/garraia/env (chown root:garraia + chmod 640 — the unit already loads it via EnvironmentFile). Never commit a .env. Then restart and verify — a green /health proves nothing about auth:

garraia config check --strict # expect exit 0 — exits 2 if this shell exports
# HOST=0.0.0.0 without gateway.api_key AND without
# in-process TLS, i.e. the §7.6 layout with TLS at
# the reverse proxy (see §5.1)
# journal/log: "garraia-auth wired (login + signup pools + jwt)"
curl -si -X POST http://127.0.0.1:3888/v1/auth/signup \
-H 'Content-Type: application/json' \
-d '{"email":"op@example.com","password":"<strong password>"}' # 201/409, NOT 503
curl -si -X POST http://127.0.0.1:3888/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"op@example.com","password":"<strong password>"}' # 200/401, NOT 503

Keep the gateway bound to 127.0.0.1 and terminate TLS at a reverse proxy for any external exposure (docs/src/production-runbook.md §2 — release binaries do not carry the tls cargo feature).


8. Cross-references​

  • ADR 0005 — identity provider architecture (BYPASSRLS roles).
  • Plan 0010 — v1/auth/* endpoints.
  • Plan 0011 — AuthConfig introduction.
  • Plan 0024 — /metrics auth.
  • Plan 0035 — config check.
  • Plan 0036 — Argon2id lazy upgrade (removes PBKDF2 writes).
  • Plan 0046 — this slice: JWT secret centralization + fail-closed.