JWT Auth: BetterAuth ES256 via JWKS, the GHA OIDC Machine Issuer, and RBAC
traceo intermediate 7 min read
ELI5
Every request brings a passport, which is a JWT. The web app (BetterAuth) stamps it with a private key, and publishes the matching public key at a well-known address. Both Python services fetch that public key and check the stamp. Nothing shares a password any more. There is one other passport office: GitHub Actions can stamp a passport for a CI job, and Traceo lets that job read one kind of thing and nothing else.
Technical Deep Dive
Source: traceo-cat/web/apps/client/src/lib/auth.ts:184-215, traceo_mcp_server/traceo_mcp_server/auth/jwks_cache.py:22-35, auth/jwt.py:109-111 and :157-270, auth/gha_oidc.py:1-79, auth/middleware.py:68-77 and :354-361, auth/rbac.py:56-72 and :394-445, config.py:57-59, engine/src/engine/auth/jwks_cache.py, engine/src/engine/auth/jwt.py:83-170; decisions 0083, 0094, 0098, 0104, 0109; live GET https://app.traceo.cat/api/auth/jwks; traceo-cat origin/main 7d647ab, read 2026-10-25
Issuer 1: BetterAuth, ES256 via JWKS
The web client’s BetterAuth jwt() plugin signs with keyPairConfig: { alg: "ES256" }, 15-minute tokens (auth.ts:197, :202). It deliberately avoids BetterAuth’s EdDSA default: python-jose cannot build OKP/Ed25519 keys, and under EdDSA every authenticated /api/* call 500’d. The live JWKS on 2026-10-25 published one key, EC P-256 ES256. definePayload re-reads workspaceId and role from the database at mint time, so a workspace switch reaches the next token without waiting out the 5-minute session cookie cache.
Both Python tiers fetch https://app.traceo.cat/api/auth/jwks (TRACEO_JWKS_URL, 5-minute in-process cache) and accept only an asymmetric allowlist: EdDSA, RS256/384/512, ES256/384/512 (jwt.py:109-111). Some docstrings still say “EdDSA”. The deployed algorithm is ES256.
| Tier | Symmetric (HS256) arm | Role source | Decision |
|---|---|---|---|
| MCP | deleted; a missing alg no longer falls through to HS256 | token role claim (jwt.py:260), minted live from the DB | 0083 |
| Engine | deleted, along with its BETTER_AUTH_SECRET read | the token’s own role claim no longer grants capability | 0094 (accepted 2026-09-08) |
Decision 0094 does not close M-20 on its own; it says so itself.
Issuer 2: GitHub Actions OIDC, a read-only machine
Decision 0098 (accepted) admits https://token.actions.githubusercontent.com as a second issuer. Dispatch is by the unverified iss: a GitHub token never touches BetterAuth’s JWKS, and the reverse holds too.
- RS256 only, verified against GitHub’s JWKS. The URL is a code constant, not configuration.
audandissare required. The default audience ishttps://mcp.traceo.cat(TRACEO_GHA_OIDC_AUDIENCE).- The token must match an entry in
TRACEO_GHA_OIDC_TRUSTexactly, including numericrepository_id/repository_owner_id. Unknown keys invalidate the whole list. An empty or absent list switches the feature off. - A match yields principal
machine:gha-oidcwith roleviewer, which is not configurable.AuthMiddlewareconfines that principal toGETonMACHINE_ROUTE_PATTERNS, currently^/api/ariel/baselines/<id>$. Anything else returnsMACHINE_ROUTE_FORBIDDEN.ariel_syncrefuses machines explicitly.
Whether TRACEO_GHA_OIDC_TRUST is populated in production was not read for this card (UNVERIFIED). If it is empty, the feature is off.
sequenceDiagram participant C as Caller participant M as AuthMiddleware participant J as BetterAuth JWKS participant G as GitHub JWKS participant H as Handler C->>M: Authorization Bearer jwt M->>M: read unverified iss and alg alt iss is GitHub Actions M->>G: RS256 key by kid M->>M: verify aud, iss, trust entry M->>H: machine viewer, GET allowlist only else BetterAuth M->>J: ES256 key by kid (5 min cache) M->>H: UserContext sub, workspaceId, role end H-->>C: 200, 401 or 403RBAC: a hierarchy that is switched off
Role ranks viewer(0) < editor(1) < admin(2) < owner(3) (rbac.py:56-72), and @require_permission(...) decorates create_requirement, update_requirement, delete_requirement and import_requirements. But require_permission returns early unless ENABLE_RBAC is truthy (rbac.py:421, :443), and that defaults to false (config.py:57-59). Decision 0104 recorded production traceo:mcp with ENABLE_RBAC unset on 2026-10-10. Decision 0109 restates that production runs with it off. The deployed decorators are therefore no-ops (not re-read on Railway for this card).
What actually guards writes is code that reads the principal directly, plus RLS:
| Write | Real guard |
|---|---|
| relationships | services/relationships.py role check (editor/admin/owner) plus RLS (0108) |
| baselines | refused for everyone, ariel_integration/policy.py (0104, see traceo-009) |
ariel_sync | admin/owner only, never anonymous, never a machine |
| requirements | RLS INSERT/UPDATE/DELETE policies check the "user".role row (traceo-004) |
flowchart TD R["tool call create_requirement"] --> D{"ENABLE_RBAC on?"} D -- "no (production default)" --> PASS["decorator passes through"] D -- yes --> FLOOR{"role >= editor?"} FLOOR -- no --> DENY["403"] FLOOR -- yes --> PASS PASS --> RLS{"RLS members_create_requirements: user row role in owner, admin, editor"} RLS -- no --> REFUSE["InsufficientPrivilege"] RLS -- yes --> OK["row written"]Key Terms
- JWKS → the issuer’s published public keys.
jwks_cache.pyfetches and caches them on each tier. - I-4 → Traceo’s invariant that bearer tokens are verified asymmetrically via JWKS, never with a shared secret.
- Machine principal →
machine:gha-oidc. Alwaysviewer, and only allowed to GET routes on an allowlist. ENABLE_RBAC→ the env switch that turns@require_permissionon. It defaults to off.
Q&A
Q: Which algorithm does the live JWKS at app.traceo.cat publish, and why not BetterAuth’s default? A: ES256 on a P-256 key. BetterAuth defaults to EdDSA/Ed25519, which python-jose cannot load, so every authenticated backend call failed until the plugin was pinned to ES256.
Q: What happened to the HS256 arm on each tier?
A: It was deleted on both. The MCP tier went first (0083), then the engine (0094). The engine change also deleted the BETTER_AUTH_SECRET read and the path by which a token’s own role claim became a capability.
Q: What can a GitHub Actions OIDC token do once it is accepted?
A: It becomes a fixed viewer machine principal confined to GET /api/ariel/baselines/<id>. Every other route is refused, and ariel_sync explicitly rejects machines.
Q: Does @require_permission block a viewer in production?
A: No, by the recorded production config. The decorator does nothing unless ENABLE_RBAC is on, and decisions 0104 and 0109 record it off. Writes are guarded by principal checks in the service code and by RLS instead.
Examples
A petrova deep-probe workflow asks GitHub for an OIDC token with aud=https://mcp.traceo.cat and calls GET /api/ariel/baselines/BL-7. If its repository ids and workflow ref match a trust entry, the middleware lets the request through to the baseline route. That route then applies the 0104 read allowlist (BASELINE_READ_TENANT_IDS), the same as for any other caller. If it tries POST /api/requirements, it gets MACHINE_ROUTE_FORBIDDEN before any handler runs.
neighbors on the map
- Multi-Strategy Authentication in purr-api debugging 401 errors across different clients
- Airlock JWT Handoff & the smo1_session Cookie debugging login loops or session expiry
- Chronicle JWT Claims & Dev Tokens issuing tokens from an external auth provider
- LORE RBAC & Airlock Auth Flow implementing authentication in a new LORE page
- Meridian Session Cookie Lifecycle debugging unexpected redirect loops back to airlock