Fundamentals
The decision lifecycle
Every request walks the same six stages. This is the exact pipeline unpacked in the Decision Trace — in the Simulator and on every case in Review.
1
Input
who · what · device · auth
→
2
Data lookup
history · profile
→
3
Signals
geo · device · ATO · bot
4
Policy eval
rules → required AL
→
5
Decision
allow · step-up · deny
→
How it works
A request arrives with who, what and the device fingerprint. The platform enriches it with history, gathers live signals, then evaluates policy to pick the minimum friction that keeps the action safe.
An unknown customer defaults to fraud score 50, an unknown device to 0. Enrichment is always best-effort — if a source is slow or down, the decision still completes on what's available. The intelligence layer adds context; it never holds a decision up.
Worked example
login · OTP · new device
fraud 12 (LOW) · device 0 (untrusted) · ATO 1/3 · bot No → policy: AL2 sufficient → allow
FRICTIONLESS
Where this appears
Monitor — every row
Simulator — trace
Review — case timeline
Policy Lab — eval step
Fundamentals
Trust vs. friction
The goal is lopsided on purpose. Let the safe majority through untouched, and spend friction only where the risk earns it.
The minimum-friction model
Every customer action carries a question: is this really them, and is it safe right now? The platform answers it in real time and returns one of four decisions. The challenge is knowing when the risk earns the cost of adding a step.
Two design choices make that work. Risk logic lives in editable policy, so product and risk tune thresholds without an engineering release. Trust is contextual: the same customer doing the same thing can get a different answer depending on their device, their network, and how they've behaved lately.
Why a "normal" account can still get stopped
Account takeover is hard to catch because the attacker looks like the customer on paper — they're using real credentials, so the fraud score reads normal. The platform watches 3 independent signals that move the moment someone else is at the controls.
breach+
new device+
proxy
→
IDV
any 2 of 3
Any 2 of the 3 force AL4 IDV, whatever the fraud score says. This is the logic major banks run in production.
Trust ramp
deny zone · <30
step-up zone · 30–70
allow zone · >70
Where this appears
Simulator — outcome
Policy Lab — threshold
Fundamentals
Auth levels (AL1–AL4)
Four assurance levels form a ladder. Every action requires a minimum level; the step-up mechanism climbs the customer to exactly what the action needs.
The assurance ladder
AL1
Face ID / passcode
conf 45
Action tiers
Tier
Examples
Min AL
Risk ceiling
Tier1login, view balanceAL185
Tier2bill pay, P2P transferAL270
Tier3wire >£10K, change passwordAL355
Tier4account recovery, add payeeAL440
How step-up is triggered
When the current auth level is below what the action requires, policy picks the challenge mode:
AL_PLUS_1 — ask for one level above required (safety margin)
REQUIRED_AL — ask for exactly the minimum (minimum friction)
Where this appears
Simulator — auth level field
Control — tier thresholds
Signals
Risk signals
Fraud score and geography are the identity-layer signals. They come from the profile store and set the baseline risk before enrichment adjusts them.
fraud_score
0–100, higher is riskier. The confidence engine maps it to a risk level. Enrichment adjusts it additively:
LOW0–25Frictionless path
MEDIUM26–79Step-up likely
HIGH80–100Deny or IDV
Tor exit +40 · GreyNoise bot +40 · proxy/VPN +15 · email breach (2+) +20
Additive, clamped at 100. Base score is never replaced.
geography
Country code derived from the request IP via ip-api.com (cached 30 min), falling back to the customer record. Used by policy rules and the IDV routing engine.
Connected intelligence vendors
FreeNo API keyCache 30 min
Country, city, ISP, and proxy / hosting / Tor flags from the request IP. The platform's primary source of network context — no key required, no quota.
is_proxy · is_vpn → fraud +15
is_tor → fraud +40
geography → IDV vendor routing
Free tier1,000 checks/dayCache 1h
Crowd-sourced IP reputation score 0–100, where 100 means the IP has been reported for abuse many times. Used to degrade device trust — an abusive IP on a known device still pulls it down.
ip_abuse_score > 80 → device −40
ip_abuse_score > 50 → device −20
Email Breach Intelligence
HaveIBeenPwned (HIBP)
API key requiredCache 24h
Whether the customer's email address appears in a known data breach corpus. A hit with more than 2 breaches is treated as a meaningful ATO signal — attackers buy lists of highly-breached emails specifically because those credentials are tried elsewhere.
email_breached && breach_count > 2 → fraud +20 · ATS −15
Combined with new device or proxy → ATO rule fires → IDV
Bot & Scanner Detection
GreyNoise Community
Community freeReal-time
Whether the request IP is a known internet scanner or bot. GreyNoise passively observes mass scanning activity and tags IPs that are definitively non-human. A hit here almost always means automated credential stuffing — most legitimate customers will never appear in this dataset.
is_greynoise_bot → fraud +40 · fires deny_greynoise_bot rule directly
Password Breach Check
Pwned Passwords (k-anonymity)
Freek-anonymity SHA-1
Whether the submitted password appears in breach data, checked using the k-anonymity SHA-1 prefix method — only the first 5 characters of the hash are sent, so the actual password is never transmitted. A hit forces AL3 selfie or higher regardless of other signals.
password_compromised → force step-up to AL3 minimum
All enrichment runs async and cached in Redis. Every call fires without blocking the decision; results land in 30 min to 24h depending on how fast the signal changes. If a source is slow or down, the engine uses the last cached value or the static score. The intelligence layer adds context to a decision — it never holds one up.
Authentication state
current_auth_level — how the customer authenticated this session: AL1 Face ID or passcode · AL2 passkey · AL3 selfie · AL4 IDV. The confidence engine checks whether it meets the action's required AL. If it falls short, the decision engine triggers a step-up.
Where this appears
Simulator — Signals stage
Review — case detail
Control — risk bands
Signals
Composite risk score
Five components — customer, device, behavioural, network, velocity — are normalised to 0–100 (higher = riskier) and blended into one compositeRisk score. Weights live in policy, not code.
compositeRisk = Σ(component × weight) / 100
customerRisk = fraudScore (weight 40)
deviceRisk = 100 − deviceScore (weight 25)
behaviouralRisk = 100 − ambientTrust (weight 15)
networkRisk = additive sub-score (weight 15)
velocityRisk = non-burst counts (weight 5)
networkRisk = ip_abuse×0.6 + breach 25 + proxy 20 + new_device 20 + vpn 15 (cap 100)
Result is banded: LOW (0–35) · MEDIUM (36–64) · HIGH (65–100). Weights are editable in Control.
Risk ceiling per tier
Where this appears
Simulator — Score stage
Control — formula weights
Signals
Device trust
Device trust combines a stored score with live fingerprinting. A known device on a clean network scores high. A first-time fingerprint on a known customer is one of the clearest ATO tells available.
device_score
0–100, higher is more trusted. Adjusted at decision time by network signals:
In v4 these signals feed networkRisk (additive sub-score, cap 100) — they no longer mutate deviceScore or fraudScore directly.
ip_abuse_score × 0.6 (max 60) · email_breached +25 · proxy +20 · new_device +20 · vpn +15
Device fingerprinting
FingerprintJS OSS generates a stable visitorId from browser/hardware signals. If the ID is new for this customer, is_new_device=true. Combined with a breached email or a proxy, it fires the ATO rule and forces IDV.
Where this appears
Simulator — device field
Review — signal panel
Signals
Ambient trust
A running 0–100 read on each customer that carries across sessions. Unlike fraud score — a static attribute — ambient trust moves with behaviour and decays without renewal.
How it moves
Climbs with
Completed passkey+3
Completed selfie+5
Completed IDV+8
Drops with
New device−10
Detected breach−15
Velocity burst−20
Idle accounts drift back toward 50 over time — trust has to keep being renewed. In v4, ATS feeds the behaviouralRisk component of the composite score (100 − ATS, weight 15).
The effect in practice
A loyal customer who's cleared step-ups for months sits at a high score and stops getting asked. A wire that would demand a selfie just goes through. The same wire from an account showing a low score after a breach gets pushed all the way to IDV — even with a normal fraud score.
Where this appears
Simulator — Trust stage
Monitor — customer ATS
Decisions
Outcomes
Every decision resolves to one of four outcomes. Actionable ones carry a machine-readable reference ID used by the step-up journey and the Review Queue.
Decision table
Decision
Customer experience
When
Ref
FRICTIONLESSNothing. The action goes through.Low risk, auth level met—
STEP-UPOne extra challenge: passcode, passkey, selfie, or IDV.Auth gap or risk signalTXN-…
DENYAction blocked, protection message shown.High risk or velocity burstINC-…
MANUALQueued for a human operator.Elevated risk, human judgment neededCASE-…
Reference IDs
Format: PREFIX-YYYYMMDD-XXXX
TXN-20260617-K7P2 → STEP_UP
INC-20260617-A3BF → DENY
CASE-20260617-X9RQ → MANUAL_REVIEW
Where this appears
Monitor — decision column
Simulator — outcome
Review — case list
Decisions
Step-up logic
A STEP_UP decision asks the customer for one more proof of identity. The type of proof and the journey are defined in policy.
Step-up types
PASSCODE→AL1 · Face ID or PIN
PASSKEY→AL2 · Cryptographic passkey challenge
SELFIE→AL3 · Liveness-checked face match
IDV→AL4 · Full identity document check
AL_PLUS_1 — one level above required (safety margin)
REQUIRED_AL — exactly the minimum required (minimum friction)
The step-up journey
1.Decision issues a reference ID (TXN-…) and returns step_up_type to the client.
2.Customer completes (or abandons) the challenge. Client posts to POST /trust/step-up/complete.
3.Platform re-evaluates with the new auth level — typically resolves to FRICTIONLESS.
Where this appears
Simulator — journey panel
Review — case timeline
Decisions
Deny & review
DENY blocks the action outright. MANUAL_REVIEW queues it for a human operator. The distinction is whether human judgment could change the outcome.
When we deny
Tor exit node or GreyNoise-tagged bot — fraud shoots to 90+.
Velocity burst — more than 5 requests in 1 minute. Automated stuffing pattern.
High risk (≥80) + Tier4 action — account recovery or adding a new payee.
When we send to review
Elevated velocity — 10–15 requests in 5 minutes. Borderline: could be scripted, could be legitimate.
Medium–high risk on a Tier3/4 action — worth a human look before blocking permanently.
Velocity rules require Redis. When Redis is unavailable these rules are skipped and the decision carries on without them.
Where this appears
Review — case queue
Control — velocity toggle
Policy
Rules & primitives
All risk logic lives in policies/decisions.json — an ordered array of rules. First match wins. No risk logic in code.
Rule structure
{
"id": "deny_tor_exit_node",
"condition": { "is_tor": true },
"decision": "DENY",
"reason": "Tor exit node detected",
"enabled": true
}
Available conditions
riskLevel · LOW / MEDIUM / HIGH (composite band)
risk_ceiling_breached · boolean — compositeRisk > action's ceiling
alMeetsRequired · boolean — assurance gap check
geography · country code array
actionTier · Tier1–Tier4
velocity_1m_gt / 5m_gt / 15m_gt · count thresholds (Redis)
is_tor / is_greynoise_bot · hard gate flags
is_proxy / is_vpn / is_new_device / email_breached · enrichment flags
ip_abuse_score_gte / ato_signal_count_gte · numeric thresholds
Where this appears
Policy Lab — rule editor
Control — rule toggles
Simulator — Policy eval stage
Policy
Publishing
Policy changes follow a simulate-then-publish loop. Nothing goes live without a before/after comparison against real traffic.
The publish flow
1.Author the rule
Write it in Policy Lab directly, or describe it in plain language and let the AI copilot draft it.
2.Simulate
Replay the draft against recent real traffic. Read the before/after decision mix, the transition matrix, and which rules fired or never fired.
3.Publish
Click Publish in Policy Lab. The engine picks up the new config immediately — no restart.
A/B experiments
Run a draft against a deterministic slice of live traffic. The same customer always lands in the same variant (hash-based split). Compare decision mixes before a full rollout. Configure in Control → A/B Experiment.
Versioning & rollback
Every published policy is a version. The Policy Lab header shows v37 · live and v38 · 3 edits. Rollback is one click back to any prior version.
Where this appears
Policy Lab
Control — A/B panel
Signals
Velocity
Request counts in rolling time windows, tracked in Redis sorted sets. Velocity is the fastest signal the platform has — a credential-stuffing burst shows up within seconds.
Sliding windows
velocity_1mrequests in the last 60 seconds>5 → DENY
velocity_5mrequests in the last 5 minutes>10 → REVIEW
velocity_15mrequests in the last 15 minutespolicy-tunable
Windows are tracked in Redis sorted sets, one per customer. Each request is recorded with a Unix millisecond timestamp as the score. The engine trims old entries and counts in the relevant window before every decision.
When Redis is unavailable, velocity rules are skipped — the rest of the pipeline continues unchanged.
How velocity rules fire
Velocity is evaluated as a rule condition, exactly like risk level or action tier. Two built-in rules cover the most common patterns:
DENYdeny_velocity_burst — more than 5 requests in 1 minute. Automated credential stuffing. Requires Redis.
REVIEWmanual_review_velocity_elevated — more than 10 requests in 5 minutes. Elevated but borderline — human judgment needed. Requires Redis.
Both rules can be toggled on/off in Control without a deployment. They can also be disabled globally via the Velocity Enforcement switch, which is useful when Redis is first brought up in a new environment.
Velocity and ambient trust
A velocity burst also records a suspicion signal into the Ambient Trust Score (−20). So even if the burst is borderline and goes to review rather than deny, the customer's ATS degrades — meaning subsequent actions require more trust to clear.
Where this appears
Monitor — velocity column
Simulator — Signals stage
Control — velocity toggle
System
Synthetic traffic
You don't wait for real fraud to see the platform work. A traffic engine drives continuous realistic decisions that stream into Monitor in real time, and a scheduler mixes in attack scenarios on demand.
Traffic daemon
Traffic daemon
Node process under PM2, fires requests time-weighted to look like real load with an attack scheduler mixed in. Built on generate-traffic.js.
Persona bank
20–30 synthetic customers, each with a stable fraud score, device set, geography, and behaviour pattern, plus an attack probability. Stored in personas.json.
Live feed
Decisions stream into Monitor over Server-Sent Events. No polling, no websocket overhead — each decision appears within milliseconds.
6 persona archetypes
Persona
Profile
Headline signals
Expected
Laraloyal retail, UK, dailyfraud 12, known iPhone, home ISP, not breachedALLOW
Maximcautious saver, infrequentfraud 8, known Android, ManchesterALLOW
Jasonbusiness, DE/UK travelfraud 38, 3 devices, 1 old breach, large wiresSTEP-UP
Harveyhigh riskfraud 82, unknown device, VPN, 3 breaches, recoveryDENY
Bot-001credential stufferfraud 95, new device per request, AbuseIPDB 90+DENY
ATO-Nikkyaccount takeoverfraud 40 (normal), new fingerprint, Nigeria proxy, email breachedIDV
3 attack scenarios
Credential stuffing burst
Bot-001 fires 20 logins in 60 seconds across rotating IPs. velocity_1m > 5 trips and AbuseIPDB returns 90+. Watch the DENY cascade land in the live feed.
Account takeover chain
Nikky's stolen credentials from a new device, a VPN, and a breached email. The fraud score looks normal, the ATO stack fires, and the action is forced to IDV.
Mule network
5 customers send to the same new payee inside 10 minutes. The pattern surfaces across the decision log and routes the later ones to manual review.
Where this appears
Monitor — live feed
Control — daemon + attacks
System
Passkeys & authentication
Signal uses WebAuthn (FIDO2) passkeys as the primary credential — no password ever leaves the device. A PIN backup provides AL1 read-only access when the passkey device isn't available. Every login runs through the trust decision engine before a session is issued.
Why passkeys instead of passwords
A passkey is a public/private key pair generated on the user's device. The private key never leaves the device — not even to Signal's server. At login, the device signs a server-issued challenge with the private key; the server verifies the signature against the stored public key. There is no password to phish, no hash to breach, and no credential to replay.
Phishing-resistantOrigin is bound into the credential — a spoofed domain gets a different credential ID and signature verification fails.
Device-boundPrivate key is stored in the device's secure enclave (TouchID / FaceID / Windows Hello / Android biometrics). Syncs via iCloud Keychain or Google Password Manager.
No shared secretServer only stores the public key. A breach of the credential store exposes nothing exploitable.
StandardW3C WebAuthn Level 2 + FIDO2. Implemented via @simplewebauthn/server on the Node side and the browser's PublicKeyCredential API on the client.
Registration flow
First-time users prove ownership of their email via a magic link. The link sets a short-lived sig_enroll cookie that gates the WebAuthn registration endpoints.
1.Email entered → POST /auth/magic-link/request. Server generates a 32-byte random token, SHA-256 hashes it, stores hash + 15-min expiry in data/credentials.json. Raw token goes into the link; never stored.
2.User clicks link → GET /auth/magic-link/verify. Server re-hashes token, compares to stored hash, checks expiry. On match: clears token, sets 10-min sig_enroll JWT cookie, redirects to /login?step=register.
3.Passkey creation → POST /auth/webauthn/register/start. Server calls generateRegistrationOptions(), stores challenge in memory (5-min TTL), returns options JSON to browser.
4.Device prompts biometrics. Browser generates key pair in secure enclave, returns signed attestation to POST /auth/webauthn/register/finish.
5.Server verifies attestation via verifyRegistrationResponse(). Stores { id, publicKey (base64), counter, transports } in credentials.json. Issues sig_session JWT at AL2. Clears sig_enroll cookie.
6.PIN setup offered. User can optionally set a 4–8 digit PIN (stored as bcrypt hash) before entering the app.
Login flow
1.Email entered → GET /auth/user-info. Returns { has_passkey, has_passcode }. If passkey registered: go to passkey step. If not: send magic link (same as registration).
2.Challenge issued → POST /auth/webauthn/login/start. Server builds allowCredentials from stored passkeys for this email, calls generateAuthenticationOptions(), stores challenge.
3.Device signs challenge. Browser shows biometric prompt, signs with stored private key, returns assertion to POST /auth/webauthn/login/finish.
4.Server verifies signature via verifyAuthenticationResponse(). Checks counter hasn't regressed (replay protection). Updates counter in store.
5.Trust decision. getDecision() is called with current_auth_level: 'AL2'. On ALLOW/FRICTIONLESS: session issued. On STEP_UP/DENY: login blocked.
6.Session cookie set. sig_session JWT (24h, httpOnly, sameSite=strict) with { customer_id, email, achieved_al: 'AL2', device_id }.
PIN backup (AL1)
The PIN is a secondary factor for when the passkey device isn't available. It deliberately gives fewer privileges.
SetPOST /auth/passcode/set — requires active AL2 session. Stored as bcrypt hash (cost 10) in credentials.json.
LoginPOST /auth/passcode/login. bcrypt.compare against stored hash. Also runs trust decision with current_auth_level: 'AL1'. Issues session at AL1.
AL1 accessHome, Atlas, Monitor, Analytics, Review Queue — all read-only surfaces.
AL2 lockedControl Panel and Policy Lab show an in-page passkey challenge. On success the session is re-issued at AL2 and the page reloads — no redirect.
Cookies & sessions
sig_session24h JWT. httpOnly, sameSite=strict. Contains customer_id, email, achieved_al, device_id. Checked on every protected route. Verified by middleware/session.js.
sig_enroll10-min JWT. httpOnly, sameSite=strict. Set after magic link verification; cleared after passkey registration completes. Gates /auth/webauthn/register/*.
sig_device365-day UUIDv4. httpOnly=false (intentional — FingerprintJS also reads it). Used as stable device identifier fed to the trust decision engine. Set on login and passkey registration.
Credential store
data/credentials.json — file-backed, gitignored. Managed by auth/credentialStore.js. Structure:
{ "users": {
"user@example.com": {
"customer_id": "user",
"passkeys": [{ "id", "publicKey", "counter", "transports" }],
"passcode_hash": "$2b$10$…", // bcrypt, optional
"magic_token_hash": "sha256…", // cleared on use
"magic_token_expires": 1718900000000
}
} }
Key files
auth/router.jsAll /auth/* endpoints. Magic link request/verify, WebAuthn register/login, passcode set/login, logout.
auth/credentialStore.jsFile-backed CRUD over credentials.json. getByEmail, addPasskey, getPasskeyById, updateCounter, setPasscodeHash, setMagicToken.
auth/challengeStore.jsIn-memory Map. Stores WebAuthn challenges keyed by email, with 5-min auto-expiry GC.
middleware/session.jsrequireSession (verifies sig_session JWT, attaches req.user), requireAL(level) factory, issueSessionCookie, clearSessionCookie.
public/login.html4-step state machine: email → passkey login → register passkey → PIN setup. Mode switcher on passkey step sends a magic link without leaving the page.
In-page AL2 uplift
Control Panel and Policy Lab require AL2. When an AL1 (PIN) session tries to access them, the tab surface is replaced with a "Verify with passkey" prompt — no redirect to the login page. The upgradeToAL2() function in index.html runs the full WebAuthn authentication inline, calls /auth/webauthn/login/finish, and on success triggers a page reload. The server re-issues the session cookie at AL2 in the same request, so the reload sees full access immediately.
1.checkSession() stores email in _sessionEmail, calls lockAL2Tabs() when AL1.
2.Lock panel renders inline — button calls upgradeToAL2(tabId).
3.WebAuthn challenge → device biometric → /auth/webauthn/login/finish.
4.On result.ok: location.reload(). New session cookie at AL2 is in place; tabs unlock on next checkSession() call.
Where this appears
Home — user chip + AL badge
Login page — all steps
Control — AL2 lock panel
Policy Lab — AL2 lock panel
System
Architecture
The decision path is a Node.js + Express service with a strict pipeline and no risk logic in code. It degrades on purpose — each dependency can go missing without stopping decisions.
End-to-end system diagram
Internal service
External vendor (async)
Output / sink
Client request
customer_id
action
device_id
current_auth_level
ip
data/store.js
Google Sheets (optional)
Local JSON fallback
Redis score cache
Stage 2
Enrichment
async · best-effort
ip-api.com — geo + proxy/Tor
AbuseIPDB — IP reputation
HaveIBeenPwned — email breach
GreyNoise — bot / scanner
FingerprintJS OSS — device ID
Redis enrichment cache
riskEngine.js
5 components → compositeRisk → riskLevel · assurance gap → alMeetsRequired
Redis — sorted sets 1m · 5m · 15m
Redis — ambient trust score
decays to 50 · cross-session
policyEngine.js
policies/decisions.json
ordered rules · first-match wins
IDV vendor — Onfido · Persona · Jumio
Amplitude — event tracking
Postgres — decision log
decisions.jsonl — append-only fallback
SSE stream → Monitor
Redis — session store
Decision returned
FRICTIONLESS
STEP-UP
DENY
MANUAL
+ reference_id + display_message + trace
Dependency tolerance
RedisWithout it: velocity rules skip, caching falls back to uncached lookups, ATS returns 50.
PostgresWithout it: decision log writes append-only JSONL, analytics reads in-memory ring buffer.
SheetsWithout it: data source falls back to local JSON in data/.
EnrichmentWithout any given vendor: engine uses last cached value or static score. Decision never waits.
Live health
Redis
Postgres
Sheets
Adapters
Where this appears
Control — system health
Monitor — status bar
System
Integration reference architecture
The engine earns its value only if it fires across the whole bank — not once at login. This document describes how to embed the platform into a real banking estate: the integration patterns, what is decisioned versus what is enforced, the admin model, and the three-step runtime flow that turns a STEP_UP into a completed action.
The shift: perimeter → continuous authorization
Today the engine evaluates a request and returns one of four decisions. The shift being made is from perimeter authentication (decide once, at the front door) to continuous authorization (decide per action). The named pattern for this is the PEP/PDP split — separating the Policy Enforcement Point from the Policy Decision Point.
PDPPolicy Decision Point — what we already built: the decision engine, plus enrichment as the PIP (Policy Information Point) and the Policy Lab as the PAP (Policy Administration Point).
PEPPolicy Enforcement Point — the new piece. A thin hook placed in front of customer actions that calls the PDP and enforces the result.
The integration problem is entirely about the PEP: how to place a consistent enforcement hook in front of every action across a heterogeneous estate, cheaply, without forcing every team to re-plumb their service. The product being sold internally is one decision contract plus one step-up protocol, served by many enforcement adapters — the same shape that made OPA adoptable for authorization.
The four integration patterns
| Pattern | Where the PEP lives | Business context | Coverage | Friction to adopt |
| API gateway filter | Edge (Apigee, Kong, DataPower) | Weak — sees HTTP only | Blanket, single hook | Lowest |
| BFF middleware | The channel's own backend | Strong — knows the action | Per channel | Low |
| Service SDK | Embedded in each service | Strongest | Per service | Medium (code change) |
| Mesh sidecar | Service mesh (Envoy ext_authz) | Medium (header-passed) | Cloud-native services | Low where a mesh exists |
Recommendation: a contract-first hybrid, not a single pattern. Ship a small SDK that speaks a stable decide() contract, plus a BFF middleware and a gateway filter built on that same contract. The gateway gives blanket coverage from day one. The BFF and SDK supply the business context where it matters.
Lead with the BFF middleware. Every channel already has a backend that decides "do you want to log in?" — that backend is the natural choke point. It holds the action semantics and the session, so placing the PEP there needs no client rewrite and loses no context. The riskiest assumption is not the engine — it is whether channel teams will pass good context to the decision point.
Decision everywhere, enforcement selectively
A core design rule: call the engine on every action; introduce friction only when policy says so. Decoupling "decide" from "enforce" is the move that makes ubiquitous decisioning affordable.
Call everythingIncluding read-only balance checks — the ambient trust score and behavioural signal only work if the engine observes the full action stream.
Cheap callsMost calls return ALLOW in single-digit milliseconds from cache. Friction is the expensive part, and friction fires only when policy and risk score demand it.
One code pathConsistency — closes the "did someone remember to gate this endpoint?" gap across every channel.
The admin model: Action Registry
The admin surface is an Action Registry. Every action a channel can perform is registered once with its tier, required Auth Assurance Level, and risk ceiling. Channels reference an action key, not a hardcoded rule — stopping the same logical action ("make a payment") from drifting in meaning across app, web, phone, and branch.
| Registry field | Example | Drives |
| action_key | payment.new_payee | Canonical identity across channels |
| tier | 4 | Sensitivity band |
| required_al | AL3 (selfie) | The bar a step-up must clear |
| risk_ceiling | 40 | Composite-risk threshold that forces review/deny |
| step_up_grain | transaction | Whether a step-up is remembered per session or per action |
Unregistered actions fall back to a safe default: treat as Tier 3, fail-closed for money movement and fail-open for reads. That default posture is itself a policy choice, set per channel.
The runtime flow: tell, decide, step-up and retry
Three steps move a customer from intent to a completed action.
1 — Tell
The PEP intercepts the action and calls POST /trust/decision with { customer_id, action, device_id, current_auth_level, context }. It is asserting intent: the customer wants to do X, and here is what we know about them.
2 — Decide
The engine runs its pipeline and returns ALLOW, STEP_UP, DENY, or MANUAL_REVIEW. On STEP_UP, return a challenge — not just a flag — with the required acr, a max_age, and a challenge or transaction id. RFC 9470 (OAuth 2.0 Step-Up Authentication Challenge Protocol) is the standard to adopt here — WWW-Authenticate: insufficient_user_authentication with required acr_values — so the step-up plugs into any OIDC/OAuth estate.
3 — Retry
The client runs the named authenticator — the AL ladder of passcode, passkey, selfie, IDV — and the auth service mints a fresh token carrying the satisfied acr/amr. The client replays the original action with the elevated token. The engine re-decides; alMeetsRequired is now true and it returns ALLOW.
Step-up grain: session vs. transaction
Session-grainedUplift the session AL — step up once, then proceed at that tier for the session. Better UX, looser security. Right for Tiers 1–2 (viewing).
Transaction-grainedBind the step-up to one action instance via a short-lived receipt in Redis, keyed by transaction id with a TTL. Higher assurance, more friction. Right for Tier 4 (new-payee payments).
Make grain a per-tier policy knob, not a constant. Both paths fit the existing Redis and policy model with no new infrastructure.
What a smart skeptic will challenge
Latency — we just put a synchronous hop in front of everything
The slow part is enrichment, not scoring. Use tiered evaluation: low-tier actions take a fast path with cached fraud and device scores and no live enrichment, returning under 10ms; full enrichment runs only on cache miss or for elevated tiers. Breach and IP-reputation services are never called on a balance check.
Availability — the engine is now in the critical path of the whole bank
This is the serious objection. Design the fail posture per tier: reads fail-open, money movement fails-closed. Add a local PEP fallback (cached last-known decision plus a conservative default) so an unreachable engine degrades rather than blocks, circuit breakers in every adapter, and a genuinely highly-available, multi-region PDP. An architecture review board will ask this first.
Idempotency on retry
Re-deciding after a step-up must not re-run side effects — but that is the business service's responsibility via its own idempotency key, not the trust layer's. The trust call is a gate before the action; the action owns its own idempotency. Also handle DENY-after-step-up: a step-up that succeeds while a hard gate (Tor, velocity burst) still denies the retry must render cleanly on the client.
Recommended next build
The Action Registry is the next piece to build. It is the component that turns the platform from an engine into a platform — the single source of truth that every PEP reads from and that the admin console governs. Everything else (the adapters, the RFC 9470 step-up, the fail-posture policy) hangs off it.
One-liner for steering
Trust decisions as a shared bank service — like OPA for authorization, but for transaction risk and adaptive step-up. Easy to integrate (one SDK line for the common case, standards-based step-up), easy to sell internally (additive and incremental — start with Tier 4 in one channel, prove the loop, then expand action by action).