En esta guía (30 elementos)
Keycloak & Identity
Keycloak & Identity Guidelines
MANDATORY FOR ALL AI AGENTS: Keycloak is an infrastructure concern. All Keycloak wiring — resource-server config, admin adapters, trusted-header filters, JWT converters — lives in the infrastructure layer, NEVER in domain or application. Domain code never imports Keycloak, Spring Security,
Jwt, orSecurityContextHolder. The acting user reaches use cases only through a Current User Port (see security.md §4). This guide specializes the generic AuthN/AuthZ baseline in security.md for Keycloak specifically.
Project-agnostic: these guidelines describe patterns, not a specific project. Code samples use placeholder package
com.example.platform, realmplatform, service nameplatform-service, and rolesplatform-{role}. Replace with your project’s actual values when applying patterns.
Companion guides:
- security.md — generic AuthN/AuthZ, Current User Port, secrets, STRIDE. Read first. This guide does not repeat it.
- api-gateway.md — edge JWT validation, JWKS, claims-to-headers forwarding, skip-paths. The gateway-first topology below depends on it.
- hexagonal-architecture.md — port/adapter placement, decorator/CoR for cross-cutting concerns.
- spring-boot.md — Spring Security bean wiring, profiles, config properties.
- testing.md — Testcontainers, negative-path testing.
- adr.md — capture the topology choice (gateway-first vs per-service resource server) as an ADR.
Table of Contents
- When to Use This Guide
- Integration Topology — Decision First
- Keycloak Server Setup (realms, clients, roles)
- Topology A — Gateway-First (Trusted Headers)
- Topology B — Per-Service Resource Server
- Current User Port (both topologies)
- Authorization
- The Dedicated Auth Microservice (Admin API adapter)
- OAuth2 Grant Flows — Selection Matrix
- Service-to-Service & Token Propagation
- Secrets & Configuration
- Multi-Tenancy
- Testing
- Anti-Patterns
- Pre-Merge Checklist
1. When to Use This Guide
Read this whenever you:
- Add authentication or authorization to a service that uses Keycloak as IdP.
- Configure a Spring Security resource server against Keycloak (
issuer-uri,jwk-set-uri). - Build or extend an auth service that drives the Keycloak Admin API (create user, assign roles, exchange tokens).
- Map Keycloak realm/client roles to Spring Security authorities.
- Consume gateway-injected identity headers (
x-user-*) inside a microservice. - Decide token flow (Authorization Code + PKCE, client_credentials, token exchange, ROPC).
Everything not Keycloak-specific (STRIDE, secrets backend, crypto, rate limiting, the CurrentUserPort contract itself) stays in security.md. This guide only adds the Keycloak layer.
2. Integration Topology — Decision First
There are two valid topologies. Choose one per platform and record it as an ADR — mixing them silently is an anti-pattern (§14).
| A. Gateway-First (Trusted Headers) | B. Per-Service Resource Server | |
|---|---|---|
| Who validates the JWT | API gateway at the edge (any gateway per api-gateway.md) via JWKS | Each service, via spring-boot-starter-oauth2-resource-server |
| What the service receives | x-user-id, x-user-roles, … HTTP headers |
Raw Authorization: Bearer <jwt> |
| Service ↔ Keycloak coupling | None at request time (no JWKS calls per service) | Each service caches JWKS from Keycloak |
| Latency / fan-out | Lowest — one validation at the edge | Each service re-validates (JWKS cached) |
| Hard requirement | Services MUST NOT be reachable except through the gateway (network policy / mesh) | Services may be directly reachable; each is self-defending |
| Best for | Platforms already behind a trusted gateway (see api-gateway.md) | Services exposed directly, public APIs, zero-trust internal mesh |
| Baseline reference | api-gateway.md §claims-to-headers | security.md §2.2 |
Default for a gateway-fronted platform: Topology A. The edge does JWT work once; downstream services trust normalized headers. Choose Topology B when services are directly reachable or the network boundary cannot be guaranteed.
The trust boundary is the whole game. Topology A is only safe if a service can never receive a request that skipped the gateway. Enforce with a Kubernetes NetworkPolicy / service-mesh authorization policy that allows ingress to app pods only from the gateway. Without that, any pod in the cluster can forge x-user-roles: platform-manager. If you cannot guarantee the network boundary, use Topology B.
ADR trigger: the topology choice, and any later switch, is architecturally significant — write an ADR (adr.md).
3. Keycloak Server Setup (realms, clients, roles)
3.1 Realm as code — never click-ops in shared envs
Export the realm to JSON and commit it. Import on boot for local/dev; apply via admin API or keycloak-config-cli for higher envs. Never configure a shared realm by hand — it drifts and is unauditable.
# docker/docker-compose.yaml — local Keycloak, realm imported from committed JSONservices: keycloak: image: quay.io/keycloak/keycloak:25.0.6 # pin the version; do not use :latest command: ["start-dev", "--import-realm"] environment: KC_DB: postgres KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD} KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} ports: ["8082:8080"] volumes: - ./keycloak:/opt/keycloak/data/import:ro # committed realm-export JSON3.2 Clients
| Client kind | Access type | Grant | Used by |
|---|---|---|---|
| Frontend / SPA | public | Authorization Code + PKCE | Browser/mobile login |
| Backend-for-frontend / gateway | confidential | Authorization Code (server side) | Server-side session, token relay |
| Auth service (admin ops) | confidential, service account | client_credentials |
Create users, assign roles via Admin API |
| Service-to-service caller | confidential, service account | client_credentials |
Machine tokens for downstream calls |
Rules:
- Public clients never hold a secret and always use PKCE. No implicit flow (deprecated).
- The admin client uses the
masterrealm (or a dedicated management realm) only for cross-realm admin; application login uses the application realm. - One client secret per client per environment. Rotate on schedule and on suspected leak.
3.3 Roles — realm vs client, and naming
- Realm roles: cross-application roles (
admin,support). - Client roles: application-scoped, prefixed by convention
platform-{role}(e.g.platform-bettor,platform-operator,platform-manager). Prefix keeps them unambiguous across clients and maps cleanly to Spring authorities. - Spring Security convention: strip the prefix and uppercase →
platform-operator→ROLE_OPERATOR. TheROLE_prefix is whathasRole(...)expects (§7). Apply the same normalization in both topologies (header filter §4.2 and JWT converter §5.1) so authority names stay identical regardless of who validated the token.
4. Topology A — Gateway-First (Trusted Headers)
4.1 Edge does the JWT work
The gateway validates signature/iss/aud/exp, then forwards normalized claims as headers and strips the raw token. Configure this per api-gateway.md §claims-to-headers. Canonical header contract:
| Header | Source claim | Notes |
|---|---|---|
x-user-id |
sub |
Stable subject identifier |
x-username |
preferred_username |
Display/audit only |
x-user-roles |
realm_access.roles + client roles |
JSON array or CSV |
x-user-type |
custom claim / role | Optional coarse partition (e.g. customer / operator / admin) |
x-geo-* |
edge geo enrichment | Optional context (country, city, ip) |
The gateway must strip any inbound x-user-* header from the client before injecting its own — otherwise clients spoof identity. Verify this in the gateway config, not the service.
4.2 Trusted-header security filter (in the service)
A shared SDK filter reads the headers, populates SecurityContextHolder, and clears it after the request. Keep it in a shared module (platform-sdk-user-security) so every service behaves identically.
// infrastructure — shared security SDK. Trusts gateway headers; never validates the JWT itself.public final class GatewayTrustedHeaderFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { try { String userId = req.getHeader("x-user-id"); if (userId != null && !userId.isBlank()) { // Defense in depth: if the raw token is still present, cross-check its `sub` // against x-user-id so a mismatched/forged header is rejected. assertSubMatchesWhenTokenPresent(req, userId);
var principal = new GatewayPrincipal( userId, req.getHeader("x-username"), GeoContext.fromHeaders(req)); var authorities = rolesFromHeader(req.getHeader("x-user-roles")); var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } chain.doFilter(req, res); } finally { SecurityContextHolder.clearContext(); // never leak identity across pooled threads } }
private List<GrantedAuthority> rolesFromHeader(String header) { if (header == null || header.isBlank()) return List.of(); return parseRoles(header).stream() // accepts JSON array or CSV .map(r -> "ROLE_" + r.replaceFirst("^platform-", "").toUpperCase(Locale.ROOT)) .map(SimpleGrantedAuthority::new) .collect(toUnmodifiableList()); }}Key rules:
- Always clear the context in
finally. Servlet threads are pooled; a leakedSecurityContextgrants the next request another user’s identity. - Cross-check
subvsx-user-idwhen the raw token is still forwarded — cheap defense against a misconfigured edge. Decode the JWT with a real library (NimbusJWTParser), not hand-rolled Base64 splitting. - The filter belongs in a shared SDK module, registered by each service’s
SecurityConfig. Do not copy-paste per service. - Decode the JWT
subwith a real library (NimbusJWTParser), never hand-rolled Base64 segment splitting — the latter breaks on padding and malformed segments and gives a false sense of validation.
4.3 SecurityConfig (Topology A)
@Configuration@EnableWebSecurity@EnableMethodSecurity // enables @PreAuthorize (§7)public class SecurityConfig {
@Bean SecurityFilterChain filterChain(HttpSecurity http, GatewayTrustedHeaderFilter headerFilter) throws Exception { return http .csrf(CsrfConfigurer::disable) // stateless, header-based .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(a -> a .requestMatchers("/actuator/health", "/actuator/info").permitAll() .anyRequest().authenticated()) .addFilterBefore(headerFilter, UsernamePasswordAuthenticationFilter.class) .build(); }}Never .anyRequest().permitAll() “because the gateway already authorized.” Authorize per method with @PreAuthorize (§7); the gateway does coarse edge filtering, the service does fine-grained checks. Defense in depth.
5. Topology B — Per-Service Resource Server
Each service validates the JWT itself. Add org.springframework.boot:spring-boot-starter-oauth2-resource-server and point it at Keycloak.
spring: security: oauth2: resourceserver: jwt: issuer-uri: ${KEYCLOAK_ISSUER:https://keycloak.example.com/realms/platform} # jwk-set-uri derived from issuer; override only if network topology requires itValidation verifies signature, iss, aud, exp, nbf, iat (per security.md §2.1). JWKS is fetched and cached automatically.
5.1 Convert Keycloak roles → Spring authorities
Keycloak nests roles under realm_access.roles (realm) and resource_access.<client>.roles (client). Spring’s default converter reads neither — you must supply a JwtAuthenticationConverter.
@BeanJwtAuthenticationConverter jwtAuthenticationConverter() { var converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(jwt -> { var authorities = new HashSet<GrantedAuthority>();
// realm roles Map<String, Object> realmAccess = jwt.getClaim("realm_access"); rolesOf(realmAccess).forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r.toUpperCase(Locale.ROOT))));
// client roles for this resource's clientId Map<String, Object> resourceAccess = jwt.getClaim("resource_access"); Map<String, Object> client = resourceAccess == null ? null : (Map<String, Object>) resourceAccess.get("platform-service"); rolesOf(client).forEach(r -> authorities.add(new SimpleGrantedAuthority( "ROLE_" + r.replaceFirst("^platform-", "").toUpperCase(Locale.ROOT))));
// scopes → SCOPE_* (for hasAuthority('SCOPE_...')) String scope = jwt.getClaimAsString("scope"); if (scope != null) Arrays.stream(scope.split(" ")) .forEach(s -> authorities.add(new SimpleGrantedAuthority("SCOPE_" + s)));
return authorities; }); return converter;}
@SuppressWarnings("unchecked")private static Collection<String> rolesOf(Map<String, Object> access) { if (access == null) return List.of(); Object roles = access.get("roles"); return roles instanceof Collection<?> c ? (Collection<String>) c : List.of();}Wire it into the resource server:
.oauth2ResourceServer(rs -> rs.jwt(j -> j.jwtAuthenticationConverter(jwtAuthenticationConverter())))The rest of SecurityConfig (CSRF disable, stateless, security headers) is identical to security.md §2.2.
6. Current User Port (both topologies)
Regardless of topology, use cases receive identity through the CurrentUserPort defined in security.md §4 — not SecurityContextHolder, not Jwt, not HTTP headers. Only the adapter differs.
// infrastructure/driven-adapters/security — Topology A adapter reads the populated context@Componentpublic class GatewayCurrentUserAdapter implements CurrentUserPort { @Override public AuthenticatedUser get() { var auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !(auth.getPrincipal() instanceof GatewayPrincipal p)) throw new UnauthenticatedException(); return new AuthenticatedUser( new UserId(p.userId()), rolesOf(auth.getAuthorities())); }}Under Topology B the adapter casts auth.getPrincipal() to Jwt and reads claims (jwt.getSubject(), jwt.getClaimAsString("tenant_id")) — see security.md §4. The port signature is identical, so domain/application code is unchanged when you switch topology. That is the payoff of the port.
Convenience accessors — a static SecurityUtils.getCurrentUserId(), a Principal record, an @CurrentPrincipal argument resolver for controllers, an audit-context provider fed from the principal — are fine in the infrastructure layer for entry-point ergonomics, but application/domain code must go through CurrentUserPort.
7. Authorization
7.1 RBAC at the adapter, never the use case
Enforce with @PreAuthorize on inbound adapters (REST controllers, GraphQL resolvers, message consumers) — never on use cases. This is the security.md §3 rule; Keycloak changes nothing about it.
// infrastructure/entry-points/graphql — role from Keycloak, enforced at the resolver@PreAuthorize("hasRole('OPERATOR')")public AccountView me(@CurrentPrincipal GatewayPrincipal principal) { return getAccount.execute(new AccountId(principal.userId())).toView();}hasRole('OPERATOR') matches authority ROLE_OPERATOR; hasAuthority('SCOPE_invoice:write') matches a scope. Keep the mapping (§3.3 / §5.1) consistent so these expressions stay predictable. The same @PreAuthorize placement applies to REST controllers, GraphQL resolvers, and message consumers alike — all are inbound adapters.
7.2 Fine-grained (ABAC / ownership)
- Ownership / attribute rules → express as a domain policy (
InvoicePolicy.canRead(viewer, invoice)), pure and testable, called by the adapter. Not@PreAuthorize. (security.md §3.3.) - Keycloak Authorization Services / Policy Enforcer (resource-scope model enforced by Keycloak) is available for centrally-managed, resource-based permissions. Use it deliberately, not by default:
- Pros: permissions managed in Keycloak, no redeploy to change policy.
- Cons: couples request path to Keycloak, harder to test, opaque to code review.
- Keep it
DISABLEDby default and behind a flag; enable only where central policy management is a real requirement. When enabled, map path+method → resource+scope explicitly in config (e.g.POST /api/v1/users→CREATEscope on theusersresource), and pick an enforcement mode (ENFORCINGvsPERMISSIVE) deliberately.
7.3 Defense in depth
- Negative tests for every protected endpoint: anonymous, wrong role, wrong tenant (§13).
- Postgres RLS for multi-tenant data even when the app filters (§12, security.md §16).
8. The Dedicated Auth Microservice (Admin API adapter)
User lifecycle (register, verify, change password, assign roles, logout, token exchange) belongs in one auth service that owns the Keycloak Admin API. Other services never call Keycloak Admin directly. Model it hexagonally: a KeycloakService output port, implemented by an Admin-API adapter.
// application/port/outbound — application depends on this port, not on Keycloakpublic interface KeycloakService { UserId createUser(NewUser user); void assignClientRoles(UserId id, Set<String> roles); TokenPair authenticate(Credentials credentials); TokenPair exchangeToken(String subjectToken, String targetAudience); void changePassword(UserId id, NewPassword password); void logout(UserId id); void logoutAllSessions(UserId id);}// infrastructure/driven-adapters/keycloak — the only place that talks to Keycloak Admin API@Componentpublic class KeycloakAdapterService implements KeycloakService {
private final RestClient rest; // configured with timeouts from KeycloakProperties private final KeycloakProperties props;
@Override public TokenPair authenticate(Credentials c) { String token = adminOrUserToken(c); // master realm for admin ops, app realm for users // ... POST {host}/realms/{realm}/protocol/openid-connect/token } // Bearer the acquired token on every Admin API call; centralize error mapping in a KeycloakErrorHandler.}Rules:
- Adapter is the only Keycloak Admin client. Domain/application see only
KeycloakService+ domain types (UserId,TokenPair). - Bind connection settings (host, realm, clientId, secret, grant, scopes, timeouts) in a typed
@ConfigurationProperties(KeycloakProperties), never scattered@Value. - Centralize HTTP error translation (
KeycloakErrorHandler) → domain exceptions; never leakRestClientExceptionupward. - Use a dedicated
RestClient/WebClientbean with explicit connect/read timeouts. - Prefer the official
keycloak-admin-clientlibrary over hand-rolled Admin REST calls where it covers the operation; wrap it behind the sameKeycloakServiceport either way.
9. OAuth2 Grant Flows — Selection Matrix
| Flow | Use for | Verdict |
|---|---|---|
| Authorization Code + PKCE | Any interactive login (web, SPA, mobile) | ✅ Default for user login |
| client_credentials | Service-to-service, machine tokens, admin automation | ✅ Default for non-interactive |
| Token Exchange (RFC 8693) | Downstream call on behalf of a user with a narrowed audience/scope | ✅ When delegation is required |
| Refresh token (rotation) | Renew access without re-login | ✅ With rotation + revocation |
| Resource Owner Password Credentials (ROPC) | Legacy/first-party where the service holds the password | ⚠️ Avoid; use only inside a trusted first-party auth service, never expose to third parties. OAuth 2.1 removes it. |
| Implicit | — | ❌ Forbidden (deprecated, token in URL) |
- Access tokens short-lived (≤15 min); refresh tokens rotated and revocable (security.md §2.1).
- If an auth service uses ROPC internally for first-party login, keep it behind the auth service boundary — clients hit the auth service, which holds the confidential client, never Keycloak’s token endpoint directly. Plan migration to Authorization Code + PKCE.
ADR trigger: choosing ROPC over Authorization Code + PKCE is a security-significant decision — record it with the migration path.
10. Service-to-Service & Token Propagation
- Machine tokens: downstream calls use
client_credentialswith a service-account client scoped to least privilege. Never forward a user’s access token across a service boundary as the service’s own credential. - On-behalf-of: when a downstream call must carry the user’s identity, use Token Exchange to mint a narrowed token for the target audience, or (Topology A) rely on the gateway forwarding
x-user-*headers on the internal hop behind the network boundary. - Internal transport: mTLS or service mesh between services (security.md §2.3). Trusted headers (Topology A) are only safe inside that boundary.
- Outbound Bearer: attach the machine token per request; refresh before expiry with a small skew. Do not cache a token past its
exp.
11. Secrets & Configuration
- Client secrets, admin passwords, DB passwords: environment variables / secret manager (AWS Parameter Store, Vault, Sealed Secrets), never committed. See security.md §5 and gitops-argocd.md secrets section.
- Typed
KeycloakProperties(@ConfigurationProperties(prefix = "keycloak")) with env placeholders:
keycloak: host: ${KEYCLOAK_HOST:http://localhost:8082} realm: ${KEYCLOAK_REALM:platform} admin-client-id: ${KEYCLOAK_ADMIN_CLIENT_ID:admin-cli} client-id: ${KEYCLOAK_CLIENT_ID} client-secret: ${KEYCLOAK_CLIENT_SECRET} # from secret manager, never a literal scopes: "offline_access uma_authorization"- Per-profile config: local (docker-compose) vs develop/cert/prod (secret manager). Prod profiles pull sensitive values from the secret backend, not
application.yml. - Realm-export JSON committed for import must not contain real secrets — placeholders only; inject secrets at runtime.
12. Multi-Tenancy
Pick a model and record it as an ADR:
| Model | Isolation | Cost | When |
|---|---|---|---|
| Realm per tenant | Strongest (separate users, roles, keys) | High (realm sprawl, admin overhead) | Few, high-value, strictly-isolated tenants |
| Client per tenant (shared realm) | Medium | Medium | Many tenants, shared user base |
Claim-based (tenant_id claim, shared realm+client) |
App-enforced | Low | Many tenants, app already tenant-aware |
- Claim-based is the common default: Keycloak emits a
tenant_idclaim (via a protocol mapper), theCurrentUserPortexposes it, and the app scopes every query by tenant plus Postgres RLS as a backstop (security.md §16). App filter alone is not isolation. - A common shape is a single application realm with client roles for coarse partitioning plus app-level tenant scoping — claim/role-based, not realm-per-tenant — reserving realm-per-tenant for the few cases that truly need cryptographic and administrative isolation.
13. Testing
Auth is exactly where tests are skipped and breaches happen. Cover both happy and negative paths.
13.1 Resource server (Topology B) — no live Keycloak needed
Use Spring Security’s jwt() request post-processor to forge an authenticated request with chosen claims/authorities:
mockMvc.perform(get("/api/v1/invoices/42") .with(jwt().jwt(j -> j.claim("sub", "user-1")) .authorities(new SimpleGrantedAuthority("ROLE_OPERATOR")))) .andExpect(status().isOk());Test the JwtAuthenticationConverter directly with a hand-built Jwt carrying realm_access.roles / resource_access to prove role mapping.
13.2 Trusted-header (Topology A)
Drive the filter with forged headers; assert authority mapping and the negative cases (missing x-user-id → 401/anonymous, spoofed inbound header stripped, sub≠x-user-id → rejected).
13.3 Integration against real Keycloak — Testcontainers
For the auth service and end-to-end token flows, prefer dasniko/testcontainers-keycloak with a committed realm import over docker-compose or hand-mocked beans — it gives a real token endpoint, real JWKS, and runs in CI without external state.
@Containerstatic KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:25.0.6") .withRealmImportFile("keycloak/platform-realm.json");Prefer testcontainers-keycloak over a hand-mocked KeycloakService bean or a shared docker-compose Keycloak for integration tests: a mock bean returns canned tokens that never exercise signature or claim validation, and a shared compose instance introduces external state CI cannot reset. The container gives a real token endpoint and JWKS, reset per run.
13.4 Mandatory negative tests
For every protected endpoint: anonymous, valid-but-wrong-role, wrong-tenant. A green suite with only happy-path auth tests is a false signal.
14. Anti-Patterns
| ❌ Anti-pattern | ✅ Correct |
|---|---|
Importing Keycloak / Jwt / SecurityContextHolder in domain or use cases |
Access identity via CurrentUserPort; Keycloak stays in infrastructure |
| Mixing Topology A and B silently across services | Choose one per platform, record as ADR, enforce consistently |
| Topology A without a network policy locking ingress to the gateway | NetworkPolicy / mesh authz so services are unreachable except via gateway |
.anyRequest().permitAll() “because the gateway authorized” |
Per-method @PreAuthorize; edge + service = defense in depth |
| Hand-rolled Base64 JWT decoding | Nimbus JWTParser / resource-server validation |
Default converter with Keycloak (roles under realm_access) |
Custom JwtAuthenticationConverter reading realm_access + resource_access |
@PreAuthorize on use cases |
@PreAuthorize on inbound adapters only |
Not clearing SecurityContextHolder after the request |
Clear in finally — pooled threads leak identity |
| Every service calling Keycloak Admin API | Single auth service owns the Admin API via KeycloakService port |
| Forwarding the user’s access token as a service’s own credential | client_credentials machine token, or Token Exchange for on-behalf-of |
| ROPC exposed to third parties | Authorization Code + PKCE; ROPC only inside a first-party auth service |
Client secret / admin password in application.yml or realm-export |
Secret manager + env placeholders |
| Only happy-path auth tests | Negative tests: anonymous, wrong role, wrong tenant |
| Realm configured by hand in shared envs | Realm as committed code, imported/applied reproducibly |
15. Pre-Merge Checklist
- Topology (A gateway-first / B resource server) is explicit and recorded as an ADR.
- No Keycloak / Spring Security /
Jwt/SecurityContextHolderimport in domain or application. - Identity reaches use cases only through
CurrentUserPort. - Topology A: network policy restricts service ingress to the gateway; filter clears
SecurityContextinfinally; inboundx-user-*stripped at edge;subcross-checked with a real JWT parser. - Topology B: custom
JwtAuthenticationConvertermapsrealm_access+resource_accessroles;issuer-uriset; signature/iss/aud/expvalidated. - Roles follow the
platform-{role}→ROLE_{ROLE}convention consistently. -
@PreAuthorizeon inbound adapters only; fine-grained rules as domain policies. - Keycloak Admin API accessed only through the dedicated auth service’s
KeycloakServiceport. - Grant flows correct: Auth Code + PKCE for login,
client_credentialsfor machines, no implicit, ROPC only first-party. - Access tokens ≤15 min; refresh rotation + revocation.
- Secrets from secret manager; none in
application.ymlor committed realm-export. - Multi-tenancy model chosen; claim-based paths backed by Postgres RLS.
- Tests cover role mapping + negative paths (anonymous, wrong role, wrong tenant); integration uses
testcontainers-keycloak.
Specializes security.md for Keycloak. For the generic AuthN/AuthZ baseline, Current User Port contract, secrets, and STRIDE, read security.md first. For edge JWT validation and claims-to-headers, read api-gateway.md.