Case study, super app
A step-up authentication engine where the rules are data
I own the challenge-chain service, which decides what a customer has to prove before a sensitive action. This is how it is put together and what the design made easier.
The problem
In a super app, some actions need more than a login: a transfer, a login from a new device, setting a PIN. Each one needs its own checks, and the rules change often. When those rules are branches in code, every change means a code change and a release.
How it fits
The BFF knows why a check is needed, so it starts a chain for the action. The app answers each challenge through the gateway. Once the chain is done, the service that performs the action asks the challenge-chain service to confirm the session met the requirement, and only then goes ahead.
A chain is data
An action maps to a chain. A chain says what the customer has to prove, and the engine plans the steps that satisfy it. Chains and actions are stored as JSONB and promoted between environments with Liquibase. Versions are append only, so every attempt stays readable against the version it ran on.
A login from a new device, for example, can ask for a PIN, a selfie, an ID card photo and an NFC read of the chip. A transfer above a threshold can ask for a selfie on top of an OTP.
Assurance levels and floors
Each proving step counts toward one category of evidence, and the categories add up to an assurance level. Each customer tier has a floor in config that an action has to reach. Setting up a PIN or accepting terms counts toward nothing, since enrolling a credential says nothing about who is holding the phone.
| Challenge | Evidence it counts as |
|---|---|
| Password or PIN | Knowledge |
| OTP | Possession |
| Selfie | Biometric |
| ID card photo | Document, visual |
| NFC chip read | Document, chip |
Rules before a version goes live
Every new chain version has to pass a validator before it can be used. Among the rules it enforces:
- every step is a challenge type the engine supports
- an action's chain reaches the floor for its tier
- a new version cannot lower the level an action already had
- callers cannot send attributes that only the system may write
All config goes through this one validator, so a config source added later cannot skip it.
Inside the service
The service has three modules. core holds the engine, the planner, the validator and the assurance rules, and ArchUnit fails the build if it imports Spring Web, JPA, Redis, Kafka or a vendor SDK. api is the only module that knows HTTP. infra holds one adapter per challenge type, plus the stores. The catalogue of challenge types is built from the registered adapters at startup, and the service refuses to boot if two adapters claim the same type.
Sessions live in Redis with a TTL. Config and the attempt history live in PostgreSQL. The start of an attempt is written synchronously before an OTP is spent, so losing Redis never loses the record of what a customer used.
core, and ArchUnit keeps it that way.Keeping the hot path cheap
Profiling showed a chain start costing three queries and a switch costing five. I put a Caffeine cache in front of the config reads on the hot path, with TTL-bounded staleness across pods and local invalidation when new config is appended.
What it bought
- A new chain or a new threshold is a config change, promoted through environments like any other migration.
- A new kind of challenge is one new adapter, and the engine stays the same.
- The assurance level is computed, so a chain cannot claim more than its steps prove.
- Swapping a vendor stays inside
infra.
- Java 25
- Spring Boot 4
- PostgreSQL
- Redis
- Caffeine
- Liquibase
- ArchUnit
Details are simplified and the client is not named.