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.

Where the challenge-chain service sits The BFF starts a chain, the app answers challenges through the API gateway, the service verifies answers with one adapter per challenge type, keeps sessions in Redis and config plus history in PostgreSQL, and the downstream service validates the session before acting. Mobile / web app API gateway BFF Service doingthe action Challenge chain engine · planner validator assurance rules Adapters Credential OTP Selfie ID card NFC PIN, T&C Redissessions PostgreSQLconfig · history 1 2 3 start answer validate
1. The BFF starts a chain for an action. 2. The app answers each challenge. 3. The service doing the action checks the result before it acts.

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.

ChallengeEvidence it counts as
Password or PINKnowledge
OTPPossession
SelfieBiometric
ID card photoDocument, visual
NFC chip readDocument, 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:

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.

Module layout of the service The api and infra modules both depend on core. Core has no framework or vendor dependencies. Infra implements the ports with adapters, a Redis session store, a PostgreSQL config store and a Caffeine cache. api controllers DTOs · error envelope the only module that knows HTTP core engine planner · validator assurance rules ports no Spring Web, JPA, Redis, Kafka or SDKs infra one adapter per challenge type Redis sessions PostgreSQL · Caffeine calls implements
Dependencies point at 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

Details are simplified and the client is not named.