← Projects

Identity
Infrastructure

A shared identity layer (Keycloak for local dev, AWS Cognito for staging/prod) that two separate applications authenticate against, with one custom role attribute flowing consistently through both providers’ issued tokens.

Consumers WhatsNextPlease & HCC CRM
Providers Keycloak (dev) · AWS Cognito (staging/prod)
Repo identity-provider-shared-infra
Deploy AWS CDK, manual

The Problem

Two apps, one
identity, no repeats.

WhatsNextPlease and the HCC CRM platform (case study) are two separate products with separate codebases, but they needed to share the same users, the same role model, and the same authentication contract, rather than each building and maintaining its own OAuth integration from scratch.

That meant a single provider strategy that worked in two very different environments: Keycloak running locally for development (free, self-hosted, fast iteration on realm config) and AWS Cognito in staging and production (managed, no infra to babysit). Both had to issue tokens carrying the same custom wnp_role attribute, so downstream services in either app could read a role claim without caring which provider authenticated the user. This page documents that shared infrastructure on its own terms: it is not tied to either consuming app’s business logic.

Requirements I Gathered

Ask the right
questions first.

Before any realm config or CDK stack was written, these questions shaped the design:

  1. Does dev need to look and feel like prod?

    No: Keycloak locally, Cognito in staging/prod. Different providers were acceptable as long as the token contract (claims, custom attributes) stayed identical across both.

  2. How does a custom role attribute travel through both IdPs?

    Neither Keycloak nor Cognito exposes custom attributes by default: each needed its own schema declaration and its own protocol mapper wiring the claim into issued tokens.

  3. How do Keycloak’s role claims map onto the app’s own roles?

    Keycloak splits roles across realm_access.roles and resource_access[clientId].roles, and both had to be merged and filtered into the app’s UserGroup enum, with an explicit decision on what happens to roles the app doesn’t recognize.

  4. What’s the actual risk profile here?

    An internal tool with a small, known user base, not a public-facing consumer product. That framing deliberately shaped several later tradeoffs (token lifetimes, brute-force protection, secret storage) rather than defaulting to maximum hardening everywhere.

Architecture Diagram

Two providers, one
claim, one gap.

WNP & HCC clients (SPA / SSR) authorization-code flow dev staging / production Keycloak self-hosted, local dev components: wnp_role schema protocol mapper wires claim ID / access token wnp_role present realm_access + resource_access Cognito managed, staging / prod StringAttribute: wnp_role no Pre-Token-Gen Lambda ID / access token wnp_role not reliably set claim never actively wired Downstream service reads wnp_role claim, merges realm_access + resource_access into UserGroup enum claim present claim inconsistent
Both apps authenticate against whichever provider is active for their environment, but only Keycloak has a protocol mapper actively wiring wnp_role into every token it issues. Cognito has the attribute defined on the User Pool with nothing setting it at issuance time, drawn as the dashed red path: the one item on this page that’s unfinished work rather than a deliberate tradeoff.

Gap · Cognito role mapping

No Pre-Token-Generation Lambda trigger exists on the Cognito User Pool. The wnp_role attribute is declared but never actively read or set on token issuance, unlike Keycloak’s explicit protocol mapper.

Working as intended

The token contract itself (wnp_role, merged role claims into UserGroup) is uniform for downstream consumers regardless of provider. Keycloak’s side of that contract is fully wired; Cognito’s is the one piece left to close.

Architecture Decisions

Dual-IdP, one
claim contract.

arch-01 dual-identity-providers-split-by-environment
Decision Keycloak (self-hosted, Docker) for local development; AWS Cognito (managed) for staging and production. Both apps, WhatsNextPlease and HCC, authenticate against whichever provider is active for that environment.
Why Keycloak gives fast, free, offline realm-config iteration during development; Cognito removes the operational burden of running an IdP in production. Neither tradeoff made sense for the other environment.
Rejected A single provider everywhere: either self-hosting Keycloak in production (extra ops burden for two small apps) or requiring Cognito for local dev (network dependency, slower iteration, harder to reset state).
arch-02 custom-wnp_role-attribute-wired-into-both-providers
Decision Keycloak declares wnp_role via the components key in realm-export.json using the declarative user profile provider; Cognito declares a matching StringAttribute. Protocol mappers on both wire the attribute into ID and access tokens for both clients.
Why Downstream services in either app need to read one consistent role claim regardless of which IdP authenticated the user: the dual-provider complexity should be invisible past the token boundary.
Rejected Top-level userProfileConfig in Keycloak: not actually supported for this purpose; a real gotcha, not a stylistic choice. The components key is the correct encoding.
arch-03 merge-realm-and-resource-role-claims-into-app-enum
Decision Merge realm_access.roles and resource_access[clientId].roles, filter to only values recognized by the app’s UserGroup enum, and silently drop anything unrecognized.
Why Keycloak issues roles across two different claim shapes; the app needs one flat, typed enum. Dropping unrecognized values prevents malformed or unexpected roles from ever reaching app logic in an unexpected shape.
Rejected Throwing on an unrecognized role: would hard-fail login on any config drift between Keycloak and the app enum, which is worse than the current silent-drop tradeoff for an internal tool (see Open Concerns, below).
arch-04 public-oauth-clients-no-secret-by-design
Decision All four clients (WNP/HCC × Keycloak/Cognito) are registered as public clients: no client secret, redirect-URI allow-list only, authorization-code flow.
Why All four are browser-based SPA/SSR apps, exactly the case public clients are designed for. A secret embedded in client-side code is extractable and provides false security, not real protection.
Rejected Adding a client secret to the existing browser clients: adds complexity without adding real security. A dedicated client-credentials client would be the right addition if a server-to-server integration shows up later.

Problems Hit

Two bugs worth
writing down.

keycloak-audience-validation-failing-for-non-obvious-reasons

Symptom Token verification (jwt.verify({ audience: clientId })) was failing, and it wasn’t obvious why from the error alone.
Root cause Keycloak’s default aud claim is account, not the client ID: a Keycloak-specific behavior that doesn’t match the generic “audience = client ID” assumption baked into most JWT verification setups.
Arc Started with debug logging, then expanded to comprehensive step-by-step logging when that wasn’t enough. When logging still wasn’t converging on an answer, wrote a standalone JWT-verify CLI diagnostic script the next day to isolate the claim in question. That script is what actually surfaced the aud/azp distinction.
Fix Disabled strict validation on the aud claim; instead validate azp (authorized party) against the expected client ID, Keycloak’s actual mechanism for identifying the requesting client. Debug logging was cleaned up once the fix landed.

keycloak-custom-attribute-not-settable-via-top-level-userProfileConfig

Symptom Declaring the wnp_role custom attribute at the top-level userProfileConfig key in the realm export did not make it usable: it wasn’t a matter of syntax, it was the wrong key entirely.
Root cause Keycloak’s declarative user profile provider requires custom attributes to be declared under the components key in realm-export.json, not userProfileConfig, a real API gotcha rather than a stylistic preference.
Fix Moved the attribute declaration to the components key, admin-only editable; added a matching Cognito StringAttribute and protocol mappers on both providers so the claim reaches tokens for both clients consistently.

What Shipped

The full scope
of what went live.

Identity Providers

  • Keycloak realm config

    Declarative realm-export.json with clients for both WNP and HCC, custom attribute schema, and protocol mappers.

  • Cognito User Pool

    Separate staging/production pools, custom wnp_role attribute, app clients for both consuming applications.

  • Standalone JWT diagnostic CLI

    Built mid-investigation to isolate the audience-claim bug; kept in the repo as a reusable tool for future token issues rather than deleted as a one-off.

Claims & Roles

  • Cross-provider wnp_role claim

    Same custom role attribute available in tokens issued by either provider, abstracting the dual-IdP complexity from downstream services.

  • Role/group sync

    Merges realm_access and resource_access role claims from Keycloak into the app’s typed UserGroup enum.

Infrastructure

  • AWS CDK stack

    Provisions the Cognito User Pool(s) and app clients; deployed by hand via cdk deploy --context environment=staging/production.

  • Public OAuth clients

    Four authorization-code-flow clients (WNP/HCC × Keycloak/Cognito), no client secrets, redirect-URI allow-lists.

Open Concerns

Seven gaps, and
which ones matter.

Every item below is a real gap in the current system, none of them are hidden. Six are deliberate, risk-calibrated decisions appropriate for an internal tool at its current scale, not oversights. One (#6) is genuinely unfinished work. Being able to draw that distinction clearly (knowing which gaps are accepted tradeoffs versus which are actual to-dos) is itself the point of documenting this list.

no-token-expiry-hardening Deliberate
Concern Neither provider has custom token lifetimes: both run platform defaults (Cognito: 1hr access/ID, 30d refresh; Keycloak: 5min access, 30min SSO idle).
Why not yet Small, known internal user base, not a public-facing product: the risk of a leaked long-lived token is lower here, and platform defaults are reasonable vendor-tested starting points.
What’s next Shorter access-token lifetimes with silent refresh, especially on the Cognito WNP client which supports direct password/SRP grants: higher exposure than hosted-UI redirect flows.
no-brute-force-protection Deliberate
Concern Cognito’s AdvancedSecurityMode isn’t enabled; Keycloak’s realm export has no bruteForceProtected flag set.
Why not yet Same reasoning as token lifetimes: both are a config toggle away, never turned on because it hasn’t been needed, not because it’s hard.
What’s next Enable bruteForceProtected on Keycloak and Cognito’s AdvancedSecurityMode: low effort, meaningful defense-in-depth, worth doing regardless of current risk level.
no-secrets-manager-plain-env-vars Deliberate
Concern COGNITO_USER_POOL_ID, COGNITO_CLIENT_ID, COGNITO_DOMAIN, KEYCLOAK_URL/REALM/CLIENT_ID are all plain env vars, no secret-store indirection.
Why not yet None of these values are actually secret: they’re public OAuth config visible in browser network requests anyway. There’s no client secret in this system at all (see #5), so nothing sensitive is passed via env vars.
What’s next No change needed for current values. If a future integration introduces an actual secret, that’s the trigger to add Secrets Manager, not before.
no-ci-cd-for-cdk-deploys-fully-manual Deliberate
Concern cdk deploy --context environment=staging/production is run by hand: no pipeline, no approval gate, no drift detection.
Why not yet Identity infra changes are infrequent and deliberately high-stakes: a manual step is arguably a feature, forcing a human to consciously trigger changes to shared auth infra both apps depend on.
What’s next If deploy frequency increases, add a pipeline with a required manual approval step: preserves the human-in-the-loop property while removing manual CLI-command risk.
no-client-secret-on-any-oauth-client Deliberate
Concern All four clients (WNP/HCC × Keycloak/Cognito) are public clients with no secret: only the redirect-URI allow-list protects client identity.
Why not yet Correct behavior, not a gap: all four are browser-based apps using authorization-code flow, exactly the case public clients are designed for. A secret in browser-exposed code is extractable, so it would add false security.
What’s next No change needed unless a server-to-server integration is added, at which point a dedicated client-credentials client with a real secret would be the right addition.
cognito-role-mapping-incomplete-vs-keycloak Unfinished
Concern Keycloak has an explicit protocol mapper wiring wnp_role into tokens on every client. Cognito has the custom attribute defined on the User Pool, but no Pre-Token-Generation Lambda or equivalent: the claim isn’t actively set/read the same way.
Why not yet A genuine, currently-open inconsistency, not a deliberate design choice, unlike everything else on this list. Likely explanation: wnp_role was built Keycloak-first (same timeline as the audience-bug investigation, also Keycloak-specific), and the Cognito equivalent wasn’t finished in the same pass.
What’s next Add a Pre-Token-Generation Lambda trigger on the Cognito User Pool to mirror the Keycloak protocol mapper behavior: this is the one item here that’s an actual to-do, not an accepted tradeoff.
staging-cognito-whitelists-localhost-callbacks Deliberate
Concern Staging Cognito’s redirect-URI allow-list includes localhost:3000/3001 alongside real staging domains: broadens the trusted-redirect surface of a pool used by real (if staging) users.
Why not yet Practical convenience: lets developers point local dev builds at staging Cognito without a second, throwaway pool just for that. Staging carries a lower bar than production, which correctly has no localhost entries.
What’s next If staging ever holds anything resembling real user data, tighten this, but as long as it’s purely synthetic test data, the convenience trade is reasonable.