Identity model
The platform has three principal types — customer, agent / internal rule, and operator — each with a distinct authentication path, authority model, and audit footprint. All of them flow throughcurrent_principal in apps/api/src/neobank/security.py and land in the same frozen Principal value.
Auth0 backend-for-frontend (BFF)
The customer browser must never hold an identity token or a Perflo bearer token. The API acts as a confidential Auth0 client and runs the Authorization Code flow with PKCE itself:- The API owns the Authorization Code + PKCE exchange; the browser only ever sees an opaque, HTTP-only
session cookie (
__Host-neobank_sessionin secure runtimes). - Session state lives in Redis, keyed by that opaque cookie — no JWT in browser storage.
- Cross-site request forgery protection is enforced on every unsafe method: the
Originheader must equal the public origin andX-CSRF-Tokenmust match the session (_validate_csrf). - Sensitive writes require fresh multifactor step-up. A recent MFA session can confirm a new intent without another redirect, but it cannot authorize a different payload.
The three principal types
customer
A retail end-user. Authenticated by the BFF cookie plus a valid CSRF token. Writes that touch money — transfers, mandates, cards, provider linking — go throughrequire_step_up, which demands
has_fresh_step_up: an MFA, WebAuthn, or passkey authentication method within the freshness window.
agent / internal_rule
An autonomous OAuth client acting on a customer’s delegated authority. It presents a Bearer JWT validated against the Auth0 JWKS (JwksVerifier, RS256, audience- and issuer-bound). Authority is scope-based
(require_scope) and always tied to a specific client_id (azp or client_id claim) and to a customer
mandate. Operations and audit records retain both the customer subject and the OAuth client_id, so a
different client cannot read the result and operators can attribute every delegated action.
operator
A human reviewer using the operator console at/ops. Operators authenticate through a separate Auth0
application with mandatory MFA, carry the operator role, and are limited to view and suspend. The
customer login cannot accept the operator role, and operator sessions must originate from the dedicated
operator client — so role leakage in the customer application can never open the restricted console.
Step-up freshness
has_fresh_step_up is true only when the principal has an MFA-class authentication method and the
time since auth_time is within this 300-second window. Outside the window, a sensitive write raises
StepUpRequired and forces a fresh MFA redirect. The window is short on purpose: step-up authorizes one
transaction, not a session.
Confirmation intents
Step-up confirms intents, not requests. Each confirmation intent is bound to the customer, the action, and a canonical payload hash; it expires after ten minutes and can be consumed once. The Auth0 step-up callback confirms the intent only after matching the customer subject and MFA claim. A fresh MFA session can confirm a new intent without another redirect, but it cannot authorize a different payload — so a replayed or tampered confirmation is rejected, not silently re-approved.In
development and test with NEOBANK_DEBUG_AUTH_ENABLED=true, the X-Debug-* headers synthesize a
Principal directly, including X-Debug-AMR: mfa for step-up. Every secure runtime forbids this. See
Quickstart for the header set.Where to go next
Authentication
Integrating customer sessions and agent tokens against the API.
Agent mandates
Binding an OAuth client to one recipient and explicit limits.
Confirmation and idempotency
How step-up, confirmations, and idempotency keys compose on sensitive writes.
Adapter modes
The runtime boundary that decides which auth backends are allowed.