Skip to main content

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
└─────────────────────────┘
Current dev deployment differs from the design above

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/master200). The topology diagram above reflects the intended production design, not the current dev reality.

ECS Fargate deployment

PropertyValue (design)Value (current bopen-dev deployment)
Imagequay.io/keycloak/keycloak:26.2.5Same
Startup commandstart (production mode — not start-dev)Same
CPU / Memory0.5 vCPU / 2 GB per taskSame
TasksMinimum 2, spread across AZs1 (DesiredCount=1) — no HA clustering active
TLSACM certificate on the ALBNone — plain HTTP only
Health checkGET /health/ready on port 9000 (the management port — not the main 8080 port; Keycloak 26 moved it) → HTTP 200Same
Grace period120 s (Keycloak runs DB migrations on first boot)Same
Deployment reality check

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:

ObjectValue
Schemakeycloak_core
DB userkeycloak_user
PasswordAuto-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:

SettingValueReason
accessTokenLifespan900 s (15 min)FAPI requirement
revokeRefreshTokentrueSingle-use refresh tokens
defaultSignatureAlgorithmPS256FAPI: PS256 or ES256
bruteForceProtectedtrueAccount lockout
registrationAllowedfalseSelf-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:

  1. A bopen-portal public OIDC client (PKCE/S256)
  2. 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.
  3. 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.

Known issue

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:

ParameterValue
SMTP hostemail-smtp.eu-west-2.amazonaws.com:587
AuthSTARTTLS, SES SMTP credentials
From addressnoreply@bopenbanking.com
DKIMEnabled 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.

Not currently deployed

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:

  1. Deploy the SES stack: ./infra/cloudformation/scripts/deploy.sh ... EnableSes=true (or the equivalent layered-stack command)
  2. Add the three DKIM CNAME records (from CloudFormation outputs) to DNS
  3. Run infra/cloudformation/scripts/generate-ses-smtp-credentials.sh
  4. If SES is in sandbox mode, request production access in the AWS console
  5. Redeploy the payment-service task definition with SesSmtpCredentialsSecretArn populated so SMTP_SECRET is 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

TemplateResources
keycloak.yamlECS task + service, dedicated ALB + TG, 2 security groups, 2 Secrets Manager secrets, CloudWatch logs, rotation schedule
ses.yamlAWS::SES::EmailIdentity, IAM SMTP user, 2 Secrets Manager secrets
rds.yamlRotationLambdaToRdsIngress — 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.