Auth configuration — GarraIA Gateway
Reference for the
[auth]config block and the environment variables consumed bygarraia-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
- Set
GARRAIA_JWT_SECRET(≥32 bytes, hex/base64 both fine). Generate withopenssl rand -hex 32. - Set
GARRAIA_REFRESH_HMAC_SECRET(≥32 bytes, distinct from the JWT secret). Generate withopenssl rand -hex 32. - Optional but recommended: pin JWT access TTL and refresh TTL in the
[auth]section of your config file (see §2). - Never add
jwt_secret = "...",refresh_hmac_secret = "...", ormetrics_token = "..."toconfig.yml/config.toml. They are not parsed and would be a committable secret leak.
1. Precedence matrix
| Field | Environment variable | Config file key | Default | Fail mode when missing |
|---|---|---|---|---|
jwt_secret | GARRAIA_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_secret | GARRAIA_REFRESH_HMAC_SECRET | ❌ not accepted | ❌ | 503 from /v1/auth/* refresh flow |
login_database_url | GARRAIA_LOGIN_DATABASE_URL | ❌ not accepted | ❌ | 503 (fail-soft) |
signup_database_url | GARRAIA_SIGNUP_DATABASE_URL | ❌ not accepted | ❌ | 503 (fail-soft) |
app_database_url | GARRAIA_APP_DATABASE_URL (optional) | ❌ not accepted | — | /v1/groups-style → 503; /v1/me still works |
metrics_token | GARRAIA_METRICS_TOKEN | ❌ not accepted | — | Dedicated /metrics listener fails closed; embedded route → 503 for non-loopback |
metrics_allow_cidrs | GARRAIA_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 = 900 | 900 (15 min) | config check Error if outside [60, 86400] |
refresh_token_ttl_secs | (none) | [auth] refresh_token_ttl_secs = 604800 | 604800 (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 = 0 | 0 (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.ymlcan also carryllm.*.api_keyandgateway.api_key; on Unix the writer clamps the mode to0600(and creates it0600from birth, seewrite_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-diskconfig.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/loginPOST /v1/auth/signupPOST /v1/auth/refreshPOST /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_SECRETunlocks 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_userguard, the gateway logsgarraia-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:
| Surface | Why 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/messages | compat 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 ask | local CLI, talks to the runtime directly |
| Telegram / Discord / Slack / WhatsApp | channel adapters carry their own allowlist |
What is actually down:
| Surface | Symptom |
|---|---|
/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/verification | same 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:
| Path | Method | Gated since |
|---|---|---|
/api/* | any | #1045 |
/v1/chat/completions | POST | #1240 |
/v1/messages, /v1/messages/count_tokens | POST | #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/modelsand/.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_algorithmis not in the accepted set (HS256). - Error when
access_token_ttl_secsis outside[60, 86400]. - Error when
refresh_token_ttl_secsis outside[60, 2_592_000]or smaller thanaccess_token_ttl_secs. - Warning when none of
GARRAIA_JWT_SECRET,GarraIA_VAULT_PASSPHRASEorGARRAIA_VAULT_PASSPHRASEis 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 incheck.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_URLis missing —AuthConfig::from_envis all-or-nothing, so/auth/*and/v1/auth/*still answer 503 (previously this partial state passedconfig checkclean). - Error (network section, field
gateway.host) when the resolved bind (see §5.1) is not loopback and no gateway credential is set (gateway.api_keyorGARRAIA_GATEWAY_API_KEY):garraia startrefuses that bind (#1261), soconfig checknever reports it clean. With a credential but no TLS the finding is a Warning; with the file-only opt-outgateway.allow_unauthenticated_network_bind: trueit is a Warning, and the opt-out itself is always reported. - Warning (field
gateway.api_key) whenGARRAIA_GATEWAY_API_KEYandgateway.api_keyare 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_PASSPHRASEspelling 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:
- explicit
--host/--portflags, then - the
HOST/PORTenvironment variables (clapenv = "..."— how RunPod / container runtimes inject theirs, GAR-603), then - 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 writesgateway.api_keyon a server-like machine or whenHOSTis not loopback); - set
gateway.api_keyin 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(unsetHOST).
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 checkandgarraia doctorresolve the bind the waystartdoes, minus the flag:HOST/PORTfrom their own environment if set, otherwise127.0.0.1:3888. A non-loopback IP literal without a credential is an Error ("garraia startwill REFUSE"); a hostname other thanlocalhostgets a warning saying the check cannot judge it (startresolves it and applies the rule).- The file's
gateway.host/gateway.portare reported by a separate deprecation warning ("deprecated and not read bygarraia start") when they differ from the resolved bind. Under--strictthat warning exits 2: remove the keys. - What the check cannot see is a
--host/--portflag on a laterstart/restart(another process). That gap is closed at runtime: the boot applies the same rule to the real bind. - The dedicated
/metricslistener 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 —
AuthConfigintroduction. - Plan 0024 —
/metricsauth. - Plan 0035 —
config check. - Plan 0036 — Argon2id lazy upgrade (removes PBKDF2 writes).
- Plan 0046 — this slice: JWT secret centralization + fail-closed.