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.
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:
-
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.
-
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.
-
How do Keycloak’s role claims map onto the app’s own roles?
Keycloak splits roles across
realm_access.rolesandresource_access[clientId].roles, and both had to be merged and filtered into the app’sUserGroupenum, with an explicit decision on what happens to roles the app doesn’t recognize. -
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_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.
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.
userProfileConfig in Keycloak: not
actually supported for this purpose; a real gotcha, not a
stylistic choice. The components key is the
correct encoding.
realm_access.roles and
resource_access[clientId].roles, filter to only
values recognized by the app’s UserGroup
enum, and silently drop anything unrecognized.
Problems Hit
Two bugs worth
writing down.
keycloak-audience-validation-failing-for-non-obvious-reasons
jwt.verify({ audience: clientId })) was failing, and it wasn’t obvious why from the
error alone.
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.
aud/azp
distinction.
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
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.
components key in
realm-export.json, not
userProfileConfig, a real API gotcha rather
than a stylistic preference.
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.jsonwith clients for both WNP and HCC, custom attribute schema, and protocol mappers. -
Cognito User Pool
Separate staging/production pools, custom
wnp_roleattribute, 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_roleclaimSame custom role attribute available in tokens issued by either provider, abstracting the dual-IdP complexity from downstream services.
-
Role/group sync
Merges
realm_accessandresource_accessrole claims from Keycloak into the app’s typedUserGroupenum.
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.
AdvancedSecurityMode isn’t enabled;
Keycloak’s realm export has no
bruteForceProtected flag set.
bruteForceProtected on Keycloak and
Cognito’s AdvancedSecurityMode: low
effort, meaningful defense-in-depth, worth doing regardless
of current risk level.
COGNITO_USER_POOL_ID,
COGNITO_CLIENT_ID, COGNITO_DOMAIN,
KEYCLOAK_URL/REALM/CLIENT_ID are all
plain env vars, no secret-store indirection.
cdk deploy --context
environment=staging/production
is run by hand: no pipeline, no approval gate, no drift
detection.
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.
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.
localhost:3000/3001 alongside
real staging domains: broadens the trusted-redirect surface
of a pool used by real (if staging) users.
localhost entries.