Context
A banking customer's identity lives in four places. The login account sits in the identity provider, the authenticator devices in a separate platform, device enrolments in the bank's token service, and signing with the transaction signing service. If creating a customer fails halfway, the customer is stuck, and support cannot see why.
Constraints
- One identity spread across four systems, with no transaction that spans them.
- Every change has to be explainable later, to support and to auditors.
- Support staff need to unblock a locked customer without an engineer.
- Every service goes through the bank's security review.
- Request validatedRequired fields and uniqueness checked before any system is touched
- Authenticator user createdCustomer registered on the device platform
- Login account failedIdentity provider rejected the account
- Authenticator user removedCompensating step undoes the earlier one
- Operation loggedRequest and outcome written to the identity audit log
My responsibility
Backend engineer in the bank's delivery team, working through a software consultancy. Owned several services end to end, including the customer identity service described here.
Architecture
- Identity lifecycle
- A Spring Boot service on Java 21 that creates, updates and retires a customer's identity across Keycloak (the OAuth 2.0 and OIDC provider for the banking app), an HID authentication platform for authenticator devices, the bank's security token service and the signing service.
- Audit trail
- Every create, update and delete is written to an identity log with the request that caused it, in a schema managed by Liquibase migrations.
- Support tooling
- Paginated customer search and export, account status changes, credential reset, and reading or resetting a customer's signing OTP counter.
- Service security
- The service is an OAuth2 resource server validating JWTs. Its own calls to Keycloak and the authenticator platform use client-credential tokens cached with Caffeine.
- Engineering hygiene
- Integration tests against PostgreSQL in Testcontainers, a typed HTTP client per external system, formatting enforced in a pre-commit hook, and automated semantic release tagging.
Critical decisions
Compensating steps
No transaction spans the four systems, so creation runs as ordered steps, each with a way back. If a later step fails, the earlier ones are undone and no half-created customer is left behind.
Validate before touching anything
Required fields and uniqueness are checked before the first external call, so most failures never reach the other systems at all.
Retired accounts are disabled
Retiring a customer disables the login account and keeps it, so the history stays available to support and auditors.
Log the request behind each change
The identity log keeps the request that caused each change, so "why is this customer in this state" has an answer.
When things go wrong
| When | What happens |
|---|---|
| The login account fails after the authenticator user exists | An async listener removes the authenticator user again; the customer can be created cleanly on retry. |
| A customer is retired | Devices are unassigned, token enrolments removed and the login disabled, with the history kept. |
| A customer is locked out of signing | Support reads or resets the OTP counter through the operator API. |
| An external system rejects a call | A typed error for that system is returned and logged, so support sees which system failed. |
Result
Running in production behind the bank's mobile and web banking app.
Customers on the identity platform
“I worked with Nikola on the same team developing an enterprise banking solution. He is a skilled Java Spring developer, delivering high quality, scalable and secure code. In addition to his technical skills, he is a collaborative team member who shares his knowledge and expertise.”
Stack
Java 21 · Spring Boot 3 · Keycloak · OAuth 2.0 / OIDC · HID authentication · PostgreSQL · Liquibase · Testcontainers