Skip to main content

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 through current_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_session in 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 Origin header must equal the public origin and X-CSRF-Token must 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.
Because Auth0 and Perflo tokens stay server-side, browser compromise cannot directly read them. The customer frontend talks only to the neobank API.

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 through require_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.