Evaluation — Keycloak vs Authentik (Riff #18, Wave 2)¶
Decision-support analysis for whether to migrate the platform IdP from Keycloak to Authentik before launch. Grounded in a full inventory of the current Keycloak integration surface (2026-06-18). Final call is the operator's; this doc frames it.
TL;DR — recommendation¶
Conditional GO for Authentik, if the team values its two real advantages here; otherwise staying on Keycloak is perfectly fine. The integration is shallow and standard-OIDC, so the migration is low-effort and low-risk — and the pre-launch window is ideal (prod stack never deployed; zero production users to migrate). But the migration is also low-value unless you specifically want:
- Declarative config (blueprints) — fixes a real current pain: the Keycloak
realm-export.jsonis import-only/forward-only, so any admin-UI change drifts and is never captured back. Authentik blueprints are GitOps-native. - A clean flow model for the imminent public-fo B2C auth — public-fo has no real auth today (guest checkout). When B2C login/registration lands, Authentik's flow designer + built-in social/MFA is a nicer foundation than hand-rolled ROPC.
If neither is compelling, Keycloak works, is standard, and is a single container —
do not migrate for its own sake. (The one watch-item from the first draft — the
tenant_id claim — was traced and is dormant: see §Risks #1. No blocker remains.)
Current Keycloak surface (why migration is cheap)¶
Single realm portugal-odyssey, 5 clients (3 public PKCE consoles, 2 confidential
service clients), 3 flat realm roles (customer/partner/admin). Not used: SPIs,
custom themes, identity providers / social login, user federation, MFA / OTP, token
exchange, fine-grained authz, password policies, custom auth flows, keycloak-js
adapter, keycloak-connect. Validation is JWKS-only (RS256, live /certs, no
hard-coded keys). Downstream NestJS services (partner/contract/experience/…) don't
touch the IdP at all — they trust the gateway's x-user-id/x-email/x-tenant-id/x-roles
headers. Three components validate JWTs directly: api-gateway (middleware/auth.ts),
auth-service (shared/{express-jwt.ts,nest/jwt.strategy.ts}), and ai-service
(app/core/mcp_auth.py, Python PyJWKClient, for the MCP surface). All three use live
JWKS + issuer only (no hard-coded keys), so each needs just an issuer-URL change.
Blast radius of a migration: 2 frontend auth.service.ts files + api-gateway
middleware/auth.ts + auth-service (3 small REST modules) + ai-service mcp_auth.py +
5 compose blocks + 1 realm definition. That's it.
Doc correction (fixed this session):
CLAUDE.mdlisted auth-service as "NestJS + Apollo + Keycloak … (REST + GraphQL)" — stale; the GraphQL/Apollo surface was dropped in W2-8. Corrected here. (system-overview.mdwas already accurate — line 19 records the W2-8 drop; line 62 calls it a "Keycloak facade".) auth-service is a thin NestJS REST facade (/auth/refresh|logout|session,/users/me, DSAR).
What Authentik changes (for this footprint)¶
| Dimension | Keycloak (today) | Authentik |
|---|---|---|
| Protocol surface we use | OIDC discovery + JWKS + ROPC + /revoke |
All supported (ROPC is per-provider gated — must enable) |
| Config management | realm-export.json, import-only → UI edits drift, not captured |
Blueprints (declarative YAML, reconciled) — GitOps-native |
| Admin REST (DSAR uses 4 calls) | /admin/realms/{realm}/users/... |
Different endpoints/payloads — the one real porting unit |
| Footprint | 1 container, 1g/1cpu (heaviest non-DB) |
server + worker + redis (more containers, no big Java heap) |
| Social login / MFA / flows | available, unused | stronger flow designer + built-in social/MFA (matters for future B2C) |
| Maturity / ecosystem | very mature, Java, ubiquitous | mature, Python/Django, smaller ecosystem |
Net: Authentik covers 100% of what we use. Its differentiators that are relevant here are blueprints (config-drift fix) and flows (future B2C). Its cost is more moving parts (worker + redis) and a smaller ecosystem.
Migration effort (if GO)¶
Small. The porting units, in order:
- Reproduce the realm as an Authentik blueprint — 1 brand (realm), 5 providers/applications, 3 roles/groups, the redirect URIs, PKCE on the 3 consoles.
- Enable direct-grant (ROPC) on the partner-console + admin-app providers; confirm
the error-response shape matches what
auth.service.tsparses (response.text()on failure). - Rewrite the 4 DSAR Admin-API calls (
auth-service dsar.service.ts:92-125): user GET, sessions GET, user-disable, user-logout, plus theclient_credentialsgrab → Authentik's API shapes. - Confidential
auth-serviceclient with a service account + secret wired toOIDC_CONFIDENTIAL_CLIENT_SECRET_*; confirm/revoke+ rotated-refresh-token semantics (thepo_rt30-day HttpOnly cookie depends on them). - ~~Custom
tenant_idclaim mapping~~ — not needed (§Risks #1: claim is dormant). - Compose: swap
quay.io/keycloak/keycloak:24.0for the Authentik stack in{development,qualification}.yml; prod is greenfield (never deployed) so no prod cutover needed now.
JWKS/issuer validation in api-gateway + auth-service + ai-service (mcp_auth.py) needs
only an issuer-URL change in each (all three use live JWKS discovery; no hard-coded keys).
Risks / watch-items¶
tenant_idclaim — RESOLVED: dormant, no dependency (2026-06-18 code trace). Code readsdecoded.tenant_id/tenant_idsconditionally (api-gateway/middleware/ auth.ts:91,auth-service/shared/express-jwt.ts:80) and the realm-export has no mapper — but it doesn't matter: the only enforcement (auth-service/shared/nest/tenant.guard.ts) is defined but never applied (@UseGuards(TenantGuard)appears nowhere), and no downstream service consumesx-tenant-id(the gateway sets it; nobody reads it). The claim is multi-tenancy scaffolding that was never wired up — its absence has zero effect. So it is not a latent bug and Authentik needs notenant_idmapping. (partner.iam_tenant_idis a separate DB column onpartners, unrelated to the JWT claim.) → one watch-item off the list; migration is even lower-risk than the scorecard implies.- ROPC is the actual login path (not redirect flow). Authentik gates direct-grant; enable + verify error shapes.
/revoke+ refresh rotation parity — the cookie-session flow depends on it.- Smaller ecosystem / team familiarity — fewer Stack-Overflow answers, Python ops model vs Java. Soft cost.
Decision criteria (scorecard)¶
| Criterion | Weight | Keycloak | Authentik |
|---|---|---|---|
| Covers current OIDC needs | — | ✅ | ✅ |
| Migration cost | high | n/a (incumbent) | Low (shallow surface, greenfield prod) |
| Config-as-code / no drift | med | ❌ (lossy realm-export) | ✅ (blueprints) |
| Future B2C flows + social/MFA | med | ◑ (capable, unused) | ✅ (stronger) |
| Operational simplicity | med | ✅ (1 container) | ◑ (server+worker+redis) |
| Ecosystem / maturity | low | ✅ | ◑ |
| Pre-launch timing | — | — | ✅ (no prod users to migrate) |
Recommended next steps¶
- ~~Resolve §Risks #1~~ — done 2026-06-18 (code trace:
tenant_idclaim is dormant — guard unused, header unconsumed; no live-KC check needed). - Decide on the two differentiators (blueprints + future-B2C flows). If yes → schedule the migration in the pre-launch window (it won't get cheaper than now).
- If GO: a 1–2 day spike — stand up the Authentik stack on dev, author the blueprint,
port the 4 DSAR calls, and prove partner-console ROPC login +
/auth/refresh+ DSAR end-to-end before touching qual. - File the auth-service "no GraphQL" docs correction (
CLAUDE.md,system-overview.md).