Identity & Access Management
bopen uses Keycloak as its centralised IAM engine — deployed on AWS ECS Fargate, backed by the shared RDS PostgreSQL cluster, and fronted by its own dedicated Application Load Balancer.
Topology
Production design (2+ tasks, TLS terminated at the ALB via an ACM certificate):
[ Internet ]
│
▼
[ Keycloak ALB — auth.bopenbanking.com ]
(TLS terminated, ACM cert)
│
┌─────────┴─────────┐
▼ HTTP:8080 ▼ HTTP:8080
┌──────────┐ ┌──────────┐
│ Keycloak │ │ Keycloak │ ← 2 tasks, multi-AZ
│ Task 1 │ │ Task 2 │
└────┬─────┘ └────┬─────┘
│ Infinispan JDBC_PING │
└──────────┬──────────────┘
│
┌────────────▼────────────┐
│ RDS PostgreSQL │
│ schema: keycloak_core │ ← isolated within shared DB
└─────────────────────────┘
As currently deployed, bopen-dev-keycloak runs 1 task, not 2 — DesiredCount=1 — so there is no actual HA clustering happening right now (Infinispan reports itself as a singleton). The ALB also has no ACM certificate attached (AcmCertificateArn=""), so dev-auth.bopenbanking.com serves plain HTTP; requesting it over https:// fails to connect at all (verified: curl https://dev-auth.bopenbanking.com → connection failure, curl http://dev-auth.bopenbanking.com/realms/master → 200). The topology diagram above reflects the intended production design, not the current dev reality.
ECS Fargate deployment
| Property | Value (design) | Value (current bopen-dev deployment) |
|---|---|---|
| Image | quay.io/keycloak/keycloak:26.2.5 | Same |
| Startup command | start (production mode — not start-dev) | Same |
| CPU / Memory | 0.5 vCPU / 2 GB per task | Same |
| Tasks | Minimum 2, spread across AZs | 1 (DesiredCount=1) — no HA clustering active |
| TLS | ACM certificate on the ALB | None — plain HTTP only |
| Health check | GET /health/ready on port 9000 (the management port — not the main 8080 port; Keycloak 26 moved it) → HTTP 200 | Same |
| Grace period | 120 s (Keycloak runs DB migrations on first boot) | Same |
This topology took several iterations to get right — see Keycloak Setup → Deployment postmortem for the full list of issues hit (wrong health check port, non-idempotent migration replay, an ECS circuit breaker chicken-and-egg with the DB schema, and more) before this deployed cleanly.
Also currently true and unresolved: the bopen-dev-keycloak CloudFormation stack itself is sitting in UPDATE_ROLLBACK_COMPLETE — an earlier deploy attempt failed and rolled back, and the health-check-port and security-group fixes that make the service work today were applied directly via the AWS CLI, bypassing CloudFormation. The stack's template does now match those fixes, but until it's actually redeployed and reaches UPDATE_COMPLETE, any future cloudformation deploy against this stack risks reverting the live target group / security group configuration back to a broken state (see postmortem #7).
The start command and KC_DB=postgres together guarantee that Keycloak uses only the external RDS instance. The embedded H2 database (used in dev mode) is never active in production.
Database isolation
Keycloak runs inside the shared bopen RDS instance, isolated within a dedicated schema:
| Object | Value |
|---|---|
| Schema | keycloak_core |
| DB user | keycloak_user |
| Password | Auto-generated by CloudFormation, stored in Secrets Manager, auto-rotated every 30 days |
The keycloak_user role has USAGE and CREATE on keycloak_core only — it cannot read or write to bopen.* application tables.
See Database Schema — migration 008 for the initialisation SQL.
HA clustering — Infinispan JDBC_PING
Keycloak distributes user sessions, brute-force logs, and login tokens across running tasks via Infinispan. Cluster membership is coordinated through a JGROUPSPING table in the keycloak_core schema, removing the need for multicast or AWS Cloud Map.
Configuration:
KC_CACHE=ispn
KC_CACHE_STACK=jdbc-ping
When a new task starts, it registers its private IP in JGROUPSPING and discovers existing peers. The table is created automatically on first boot.
FAPI security baseline
Every merchant realm is provisioned with Open Banking FAPI-compliant defaults:
| Setting | Value | Reason |
|---|---|---|
accessTokenLifespan | 900 s (15 min) | FAPI requirement |
revokeRefreshToken | true | Single-use refresh tokens |
defaultSignatureAlgorithm | PS256 | FAPI: PS256 or ES256 |
bruteForceProtected | true | Account lockout |
registrationAllowed | false | Self-registration handled by portal |
Multi-tenancy — realm per merchant
Each acquirer on the platform has a dedicated Keycloak realm:
[ Master Realm ] ── Platform administrators & DevOps engineers
│
├── [ Realm: acme-corp ] ── Acme Corp staff → Acme portal
│
└── [ Realm: beta-retail ] ── Beta Retail staff → Beta portal
Master realm — reserved for internal platform operators. No merchant users or client applications live here.
Merchant realms — provisioned automatically via the self-registration flow at POST /api/v1/portal/register. Each realm gets:
- A
bopen-portalpublic OIDC client (PKCE/S256) - The first admin user, with an attempt to send a set-password email invite via SES — as of this writing, SES is not deployed (see below), so this step fails silently and the API response's claim that "an email invite has been sent" is not currently true. The user is created correctly; only the email itself doesn't go out.
- A best-effort attempt to assign a realm-admin role — Keycloak does not create this role automatically in new realms, so this step logs a warning and continues rather than blocking registration. It is not required for anything the portal itself does, since the payment service always calls the Keycloak Admin API with its own master-realm service credentials, never the merchant's token.
The azp claim in Keycloak access tokens carries the client_id string, which is used by the payment service to look up the merchant in bopen.merchants.client_id.
The self-registration handler currently sets bopen.merchants.client_id to the literal string "bopen-portal" for every merchant — the shared public client ID used for browser login in every realm. Since client_id has a UNIQUE constraint, the second successful self-registration will fail with a unique-constraint violation. This needs a genuinely per-merchant value (e.g. derived from realm_slug) before self-registration can be used beyond one-off testing. See Keycloak Setup #13 for how this was found.
Email invites — AWS SES
Keycloak is designed to send set-password and verify-email invites via SMTP, using AWS SES as the SMTP server:
| Parameter | Value |
|---|---|
| SMTP host | email-smtp.eu-west-2.amazonaws.com:587 |
| Auth | STARTTLS, SES SMTP credentials |
| From address | noreply@bopenbanking.com |
| DKIM | Enabled via AWS::SES::EmailIdentity |
SMTP credentials would be derived from an IAM access key using the Signature V4 HMAC algorithm and stored in Secrets Manager, then injected into Keycloak's realm smtpServer config on realm creation.
The ses.yaml CloudFormation template and generate-ses-smtp-credentials.sh script both exist and are ready to use, but no SES stack has actually been deployed in this environment — aws secretsmanager list-secrets shows zero SES-related secrets. No Keycloak realm currently has SMTP configured, so email invites silently fail (the registration code treats this as non-fatal — see architecture/iam#merchant-realms above). Every claim below describes the designed behaviour once the SES stack is deployed, not the current state.
Operator steps to actually deploy SES:
- Deploy the SES stack:
./infra/cloudformation/scripts/deploy.sh ... EnableSes=true(or the equivalent layered-stack command) - Add the three DKIM CNAME records (from CloudFormation outputs) to DNS
- Run
infra/cloudformation/scripts/generate-ses-smtp-credentials.sh - If SES is in sandbox mode, request production access in the AWS console
- Redeploy the payment-service task definition with
SesSmtpCredentialsSecretArnpopulated soSMTP_SECRETis actually injected
Authentication flows
Acquirer portal (PKCE OIDC)
1. User visits /portal/login → enters realm slug (e.g. acme-corp)
2. Browser redirected to {KC_URL}/realms/acme-corp/protocol/openid-connect/auth
?response_type=code&client_id=bopen-portal&code_challenge={S256}&...
3. User authenticates on Keycloak
4. Keycloak redirects → /portal/callback?code=...
5. Portal exchanges code for tokens (PKCE, no client secret)
6. Tokens stored in localStorage; portal calls /api/v1/portal/* with Bearer token
Payment API (machine-to-machine)
1. Merchant backend authenticates against their Keycloak realm
2. Receives a Keycloak access token (PS256, 15-min lifespan)
3. Sends token as Authorization: Bearer {token} to payment service
4. Payment service validates token via JWKS (fetched from {iss}/protocol/openid-connect/certs)
5. Extracts azp claim → merchant lookup in bopen.merchants.client_id
CloudFormation resources
| Template | Resources |
|---|---|
keycloak.yaml | ECS task + service, dedicated ALB + TG, 2 security groups, 2 Secrets Manager secrets, CloudWatch logs, rotation schedule |
ses.yaml | AWS::SES::EmailIdentity, IAM SMTP user, 2 Secrets Manager secrets |
rds.yaml | RotationLambdaToRdsIngress — added by keycloak.yaml to allow Keycloak tasks and rotation Lambda to reach port 5432 |
Deploy with:
./infra/cloudformation/scripts/deploy-keycloak.sh bopen-cfn-templates-... bopen-dev eu-west-2
Then run the DB init and SMTP credential scripts as prompted by the deploy output.