Skip to content

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:

  1. Declarative config (blueprints) — fixes a real current pain: the Keycloak realm-export.json is import-only/forward-only, so any admin-UI change drifts and is never captured back. Authentik blueprints are GitOps-native.
  2. 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.md listed auth-service as "NestJS + Apollo + Keycloak … (REST + GraphQL)" — stale; the GraphQL/Apollo surface was dropped in W2-8. Corrected here. (system-overview.md was 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:

  1. Reproduce the realm as an Authentik blueprint — 1 brand (realm), 5 providers/applications, 3 roles/groups, the redirect URIs, PKCE on the 3 consoles.
  2. Enable direct-grant (ROPC) on the partner-console + admin-app providers; confirm the error-response shape matches what auth.service.ts parses (response.text() on failure).
  3. Rewrite the 4 DSAR Admin-API calls (auth-service dsar.service.ts:92-125): user GET, sessions GET, user-disable, user-logout, plus the client_credentials grab → Authentik's API shapes.
  4. Confidential auth-service client with a service account + secret wired to OIDC_CONFIDENTIAL_CLIENT_SECRET_*; confirm /revoke + rotated-refresh-token semantics (the po_rt 30-day HttpOnly cookie depends on them).
  5. ~~Custom tenant_id claim mapping~~ — not needed (§Risks #1: claim is dormant).
  6. Compose: swap quay.io/keycloak/keycloak:24.0 for 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

  1. tenant_id claim — RESOLVED: dormant, no dependency (2026-06-18 code trace). Code reads decoded.tenant_id/tenant_ids conditionally (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 consumes x-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 no tenant_id mapping. (partner.iam_tenant_id is a separate DB column on partners, unrelated to the JWT claim.) → one watch-item off the list; migration is even lower-risk than the scorecard implies.
  2. ROPC is the actual login path (not redirect flow). Authentik gates direct-grant; enable + verify error shapes.
  3. /revoke + refresh rotation parity — the cookie-session flow depends on it.
  4. 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)
  1. ~~Resolve §Risks #1~~ — done 2026-06-18 (code trace: tenant_id claim is dormant — guard unused, header unconsumed; no live-KC check needed).
  2. 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).
  3. 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.
  4. File the auth-service "no GraphQL" docs correction (CLAUDE.md, system-overview.md).