How AzureBank works
The one document to read. It is complete on its own: every rule below is stated with its reason, and the links are footnotes for depth, never detours you have to take. If a sentence here only makes sense after following a link, that is a defect — report it.
What it is
A personal banking application: accounts, deposits, withdrawals, transfers between your own accounts, and transfers to another user by handle. Not a demo of CRUD — the interesting part is everything that has to be true because it moves money.
Two halves, both real and wired to each other:
backend/— .NET 10, ASP.NET Core. A REST API holding the domain, and a BFF in front of it that owns the browser session.frontend/— React 19, TypeScript, Fluent UI v9, Redux Toolkit Query. A full SPA talking to the real API through the BFF.
flowchart LR
User["Browser"]
SPA["React SPA<br/>Vite · :5173"]
BFF["BFF<br/>ASP.NET Core + YARP · :5000"]
API["REST API<br/>ASP.NET Core · :7215"]
DB[("SQL Server")]
User --> SPA
SPA -->|"HttpOnly cookie"| BFF
BFF -->|"Bearer, added server-side"| API
API -->|"EF Core"| DB
The arrow labels are the whole security story: the browser holds a cookie, never the JWT. "The JWT" is the exact claim: the browser also holds a one-shot PIN authorisation's id, between the PIN and the operation it authorises.
The one decision everything else follows from
The SPA never receives, stores or sends a JWT. It authenticates with an HttpOnly, Secure, SameSite=Strict cookie prefixed __Host-; the BFF holds the access and refresh tokens server-side and attaches the bearer header itself as it proxies. Nothing in the frontend constructs an Authorization header, and web storage holds no token, no session identifier and nothing of a real user's for a script to steal. Where the public demo is on, localStorage holds one thing a script could use: what signs in to the throwaway demo copy a visitor claimed, its password included. ADR-0063 says what that is worth to a script and when the browser removes it. With the demo off there is nothing in web storage for a script to steal.
Everything downstream is a consequence:
- No CORS anywhere. Same-origin topology, so there is no cross-origin grant to misconfigure. Cross-site state-changing requests are rejected by Fetch-Metadata headers on top of
SameSite=Strict. - The 15-minute access token is invisible. Before it expires, the BFF silently renews it with the session's refresh token, its grant; the user's session is bounded by inactivity and absolute timeouts, not by token lifetime. The grant does not rotate and lives 60 minutes from sign-in, so nothing from one sign-in outlives the session's cap. Only the BFF's own client, over loopback, can present it, and one presented after its session ended is recorded as a security event. A renewal only reads the grant, so a lost answer, to a database hang for instance, has nothing to break; under rotation it is a sign-out (ADR-0057).
- Session expiry is a data-loss event, so it is designed rather than accepted. No unsubmitted financial intent is persisted anywhere; a draft transfer is lost on expiry rather than resumed against a session that may no longer be yours.
Depth: ADR-0001 (BFF), ADR-0018 (origin hardening), ADR-0021 (silent re-mint), ADR-0057 (one grant per session), ADR-0019 (SPA/BFF contract).
Moving money once
The hard problem in a bank is not writing a row; it is guaranteeing you wrote it once when the network, the browser and the process are all free to fail halfway.
Every mutating money endpoint requires a client-generated Idempotency-Key and executes at most once per user, endpoint and key. The server fingerprints the raw request body bytes with a keyed HMAC — not the parsed JSON, so a re-serialisation with different key order is correctly seen as a different payload.
Five outcomes, and each one tells the client something different:
| The server says | It means | The client |
|---|---|---|
2xx with Idempotency-Replayed: true | This exact request already succeeded | Shows the stored result, byte-identical |
409 IDEMPOTENCY_IN_FLIGHT | A duplicate is still running | Keeps the key, retries later |
409 IDEMPOTENCY_RESULT_UNKNOWN | With applied: true: it was committed, and this answer cannot return its result. Without it: the outcome is not known | Stops. With applied: true, says the payment went through and offers the history, with no way to send again. Without it, asks the user to verify against their transactions |
422 IDEMPOTENCY_KEY_REUSE | Same key, different payload | Rotates the key |
Business 4xx | Insufficient funds, wrong PIN | Releases the key; fix and retry |
The client half matters as much as the server half, because a client that mints a fresh key after a timeout has manufactured a double-spend that the server cannot detect. So the rule is inverted from the intuitive one: a failure the server might have seen keeps the key; only a definitive answer spends it. RESULT_UNKNOWN is the one case where the correct behaviour is to stop rather than guess. When the server read the key's record as committed it says so, with applied: true, and the client says the payment went through and offers nothing that could send it again. When it did not, the client involves the user, because inventing a guess would be worse than asking. For a commit the server can prove, the 409 itself answers. The ledger can be asked too, and the client asks it after a lost answer: each row a money request writes on the caller's own accounts carries the request's key, and GET /api/transactions?IdempotencyKey= lists them, each with its key (ADR-0009). A row found says the payment went through; an empty page says it did not only once nothing sent under the key can still commit (ADR-0022).
There are no optimistic updates on money. Balances come from a refetch after invalidation. In a bank a briefly-wrong balance is a correctness failure, not a UX blemish.
The guarantee is proven, not asserted: 24 byte-identical parallel requests with one key produce exactly one execution, with mathematically exact balances, on both EF InMemory and real SQL Server, three deterministic rounds per run, in CI.
A cap per move, and a cap per day. Every money move is bounded at 100,000, one server constant the contract publishes as maximum and the forms promise back, with a test on each side (ADR-0046). A user's outgoing external transfers in a UTC day may not exceed 5,000 by default: refused at the mint as 422 DAILY_LIMIT_EXCEEDED with resetsAt, before the PIN is consulted, and decided again inside the transfer's transaction under a per-user application lock (ADR-0050).
Depth: ADR-0009 (server protocol), ADR-0022 (client protocol), ADR-0024 (why not ETag).
A second factor that is actually a second factor
Four operations need a PIN — a withdrawal, a transfer, closing an account, and the account-number reveal. The API verifies it for the first three; the reveal is gated by the BFF session alone, so a caller presenting a bearer token together with the BFF's service key reads the full number without one — and since ADR-0055 only the BFF presents that key in a deployment; the API keeps the same value to check it (SECURITY.md). Money moves carry their proof in the request: a withdrawal, like a transfer, first turns the PIN into a one-shot authorisation — bound to the account, the amount and, for a transfer, the payee; valid two minutes; spent once — presented in a Step-Up-Authorization header (ADR-0041, ADR-0042, ADR-0056). Nothing in the session authorises a payment; one PIN entry authorises one payment.
The account-number reveal is the one route that still uses the session level, and the elevation lives there rather than in the token: hit at level 1 it returns 403 with X-Auth-Level-Required; the SPA opens the PIN modal, elevates the BFF session, and the original request is replayed byte-identically at the transport layer rather than rebuilt. For the reveal there is nothing to rebuild — it is a GET with no body and no idempotency key — but the rule dates from when transfers rode the same path, where rebuilding after elevation would have changed the idempotency fingerprint and stranded a payment. That is why the interceptor replays rather than re-issues.
PINs are hashed with Argon2id plus a server-side pepper held outside the database, so a database dump alone cannot be brute-forced offline — six digits would otherwise fall instantly. Three wrong attempts lock the PIN; the failure counter is incremented atomically in SQL so parallel guesses cannot race past the limit.
Depth: ADR-0008 (step-up), ADR-0011 (pepper), ADR-0010 (lockout).
A tamper-evident record of what happened
Sixteen security events — nine successes and seven on the refusal path — each write a row to one AuditEvents table, each carrying a keyed HMAC over its own fields and the previous row's hash (ADR-0044). A success rides the act's own transaction, so a row that cannot be written stops the act; a refusal commits on its own connection at once, or its rollback would take the record with it. A refresh token presented after its session ended is recorded and revokes nothing: containment is manual, through the incident runbook (ADR-0057). Rows hold ids and no amounts; the table is never purged, and backend/tools/AzureBank.AuditVerifier verifies it. The claim is narrow: a rewrite by whoever holds the database but not that epoch's key is caught; truncating the tail past the last anchor needs no key and is not, and nothing verifies the chain on a schedule.
Not telling attackers who exists
Registration returns an identical, enumeration-neutral 409 whether the email or the handle collided; the specific reason is logged server-side only. Login is enumeration-safe and rate limited per IP with 429 plus Retry-After.
There is no user search. Sending money to someone requires knowing their handle exactly — the lookup is exact-match, rate-limited, and returns a masked display name. A substring search would be a harvesting endpoint with extra steps.
The account number is masked by default and revealed only through a PIN-gated endpoint; the full number never appears in a list response.
Honest residual: registration auto-logs-in, which means the account-existence oracle is narrowed rather than closed. Closing it needs out-of-band email confirmation, which needs email infrastructure this project does not have. That is a bounded, accepted risk, written down as a decision rather than left as an oversight. The notice recorded with a PIN enrolment or change is not that infrastructure: its delivery ends in a pickup directory, and nothing emails it (below).
Depth: ADR-0013, ADR-0014, ADR-0020, ADR-0012.
A notice the session cannot suppress
Enrolling or changing the transfer PIN writes a SubscriberNotice in the same save as its audit row, which it names by id, so an enrolment whose notice cannot be recorded does not happen (ADR-0045, ADR-0047, ADR-0052). Delivery is a separate act: a runner claims owed rows under a lease and writes each as an RFC 5322 message into a pickup directory, at-least-once (ADR-0048). Notices:Runner says which runner is configured — the API's own hosted loop, or the same sweep as an Azure Function on a timer, rehearsed locally against Azurite (ADR-0051) — and defaults to None, so notices are not sent automatically. To deliver a pending notice, run the notify command of the audit tool (backend/tools/AzureBank.AuditVerifier): it saves the notice as an .eml file in a folder you choose and marks it as delivered. Nothing in this repository sends e-mail, and the template that deploys the demo sets no runner.
Keeping the two halves honest
The frontend and backend agree because agreement is mechanically enforced, not because someone remembered:
- The API's OpenAPI spec generates the frontend's TypeScript types, and CI regenerates them and fails on any diff. A contract change that skips the frontend is a red build.
- The same spec generates Zod schemas, so the shapes are checked at runtime too, not just at compile time. Money responses and the step-up authorisations are validated fail-closed in production; every other
/api/*response that carries data validates in development and test only, where a mismatch should be loud and in production it should not take the page down. The BFF's own answers with data under/bff/auth/*are fail-closed everywhere, because they have no spec behind them and they gate authentication; the AzureTag rename, also under that path, has a schema generated from the API's contract and validates in development and test only. - Errors arrive as RFC 9457 ProblemDetails on one channel, carrying a
traceIdthat pastes straight into the trace search.
That last point is not decoration. A user reporting "it failed" can hand over a 32-character identifier that resolves to the exact request across the SPA, the BFF, the API and SQL — one trace, because the trace context propagates through the proxy hop.
Depth: ADR-0023 (validation), ADR-0016 (observability), ADR-0017 (PII-safe logs).
Three claims about the contract, and which are proven
The frontend's types and Zod validators are generated from the OpenAPI document the API generates, so three claims hang on it: the client matches the document, the document is what the code generates, and the code does what the document says (ADR-0053). CI's regeneration steps prove the first; a backend test proves the second, comparing the committed document with what the real composition generates. The third is checked, not proven: on every pull request Schemathesis drives the running API from the document and fails on a response it does not declare (ADR-0053 D6), and one set of contract assertions runs against the MSW mock and the real backend in real-stack, which ends with Playwright against the built SPA under its CSP (ADR-0029, ADR-0032, ADR-0054).
What proves it
- The backend suite, plus the proofs held behind a SQL Server flag that run in CI against a real database — the concurrency proofs, skipped locally rather than faked, and CI fails the job if they skip instead of proving anything.
- The frontend suite, against MSW mocks that are themselves validated against the real contract, so a mock that drifts fails the suite instead of passing quietly.
- No counts here, on purpose: a number written into prose goes stale within days. CI's jobs are the source.
- Architecture tests that fail the build on a layer-dependency violation.
- Schemathesis on every pull request, driving the running API from the committed document and failing on a response the document does not declare (ADR-0053 D6).
- Every pull request into
mainruns the full suite and CodeQL on three languages, andmaintakes a merge only when they pass. An AI review starts by itself or is asked for on each pull request. Each pull request is merged by hand.
Where to go next
docs/adr/ holds the decisions with their alternatives and residuals — the index names four to start with. docs/engineering-traps.md collects the things that fail silently. SECURITY.md has the security posture in one place, and docs/engineering-practices.md explains how to run and change the project. docs/audit-trail-against-real-practice.md sets the audit trail against what a real deployment would have and names the gap in each direction — read it if you want the limits before the design.