The browser had more authority than it needed
The event registration flow initially followed a common single-page application pattern: authenticate, receive an access token in JSON, keep it in JavaScript state, and attach it to later API requests.
This works, but it enlarges the consequence of any script execution problem. A malicious dependency, compromised third-party script, cross-site scripting flaw, debug statement, analytics hook, or careless state persistence can turn a browser-readable bearer token into a reusable credential.
The event form made that risk more important. It was no longer only a public catalog UI. It could create registrations and send private answers.
A browser needs the ability to perform an authorized action. It does not necessarily need to possess the reusable credential that authorizes the upstream API.
The architectural change was to put a Backend for Frontend, or BFF, between browser code and authenticated API routes. The IETF browser-based applications draft describes this as the strongest of its three browser application patterns: the backend component manages tokens in a cookie-based session and adds the access token when forwarding requests.
Concepts in plain language
- Access token: a temporary bearer credential that lets its holder call protected API operations.
- BFF: a small server-side layer dedicated to one frontend; the browser talks to it, and it talks to the private API.
- HttpOnly cookie: a cookie the browser may send but page JavaScript cannot read.
- Same-origin: the page and its BFF use the same scheme, host, and port, which makes the trust boundary easier to constrain.
- CSRF: an attack that tricks a logged-in browser into sending an unwanted request with its cookies attached.
Step 1: define the new trust boundary
The old request path looked like this:
Browser JavaScript
-> receives access token in JSON
-> stores token in memory
-> sends Authorization: Bearer <token>
-> APIThe new path became:
Browser JavaScript
-> sends same-origin request with cookies
-> frontend BFF validates request context
-> BFF opens the protected session
-> BFF adds Authorization server-side
-> private APIThe authentication response returned only the minimum session projection needed by the UI, such as the member identifier and expiration time. It did not serialize the raw access token back to the browser application.
This distinction is precise: an HttpOnly cookie still exists in browser-managed cookie storage, but JavaScript cannot read it. In the stateless version used here, the cookie contains authenticated ciphertext rather than the raw bearer token. An even stronger stateful deployment can store tokens in a server-side session store and give the browser only an opaque random session identifier.
Step 2: seal the session with authenticated encryption
The BFF received the upstream access token only on the server. It built a small session payload:
{
"accessToken": "upstream bearer token",
"expiresAt": "absolute ISO timestamp",
"subjectPublicId": "public user identifier"
}That payload was encrypted with AES-256-GCM using a 32-byte server key and a fresh 96-bit initialization vector. GCM provides confidentiality and an authentication tag, so modifying the cookie causes opening to fail.
The serialized cookie carried only:
version.keyId.iv.ciphertext.authenticationTagAssociated authenticated data bound the ciphertext to the session format, cookie name, and key identifier. This prevents a valid encrypted value from being silently interpreted in a different cookie or protocol version.
The key configuration was a keyring rather than a single key:
- the first key encrypts new sessions;
- all active keys may decrypt existing sessions;
- each key receives a non-secret derived identifier;
- retiring an old key happens only after its maximum session lifetime passes.
Production refused to start the secure session path when no real key was configured. A deterministic development-only key made local work possible without pretending that it was production-safe.
Step 3: use a host-only hardened cookie
The production cookie used this policy:
Set-Cookie: __Host-...=<sealed-value>;
Secure;
HttpOnly;
SameSite=Strict;
Path=/Each attribute has a separate purpose:
HttpOnlykeeps JavaScript from reading the value.Securerestricts transmission to HTTPS.SameSite=Strictprevents the browser from attaching it in cross-site contexts.Path=/makes the__Host-prefix valid and avoids ambiguous path shadowing.- no
Domainattribute keeps the cookie host-only. - an absolute expiration and
Max-Agealign browser lifetime with the upstream access token.
The OWASP Session Management Cheat Sheet recommends these properties while also noting an essential limitation: HttpOnly protects cookie confidentiality, but injected script can still make same-origin requests while it is executing. That is why the BFF cannot be only a cookie wrapper.
Step 4: constrain the proxy until it is not an open proxy
A route named /api/proxy/* that forwards arbitrary paths, methods, headers, and responses is a new attack surface. The BFF was instead split into two narrow route families:
- a fixed authentication allowlist for OTP request, OTP verification, provider login, refresh, and logout;
- authenticated member operations under a known upstream namespace.
The boundary applied several controls before any upstream request:
- Allow only expected HTTP methods.
- Reject missing or malformed path segments.
- Reject path separators and characters that could change routing meaning.
- Accept JSON only for mutation bodies.
- Reject a declared or measured body above 32 KiB.
- Forward only selected request headers.
- Never forward the BFF session cookie upstream.
- Forward only the dedicated refresh cookie to authentication endpoints.
- Add the bearer token only on the server.
- Disable automatic redirects and use a short upstream timeout.
The response boundary was equally narrow. It copied only safe headers such as content type, language, retry information, and request correlation. It never reflected arbitrary Set-Cookie, cache, redirect, or internal infrastructure headers.
Every BFF response used Cache-Control: no-store. Authentication errors were normalized so network or SDK details could not leak into the browser.
Step 5: defend the cookie boundary against CSRF
Moving authorization into a cookie removes token exposure from JavaScript, but browsers attach cookies automatically. That changes the dominant request-forgery threat.
State-changing BFF requests required three signals:
- a custom application header that normal HTML forms cannot add;
- a trusted
Originmatching the request origin; - Fetch Metadata that did not identify the request as cross-site.
X-Application-BFF: 1
Origin: https://the-current-origin.example
Sec-Fetch-Site: same-originRequests with Sec-Fetch-Site: cross-site were rejected before body parsing or upstream access. When Fetch Metadata was unavailable, strict origin rules supplied the fallback rather than accepting an unknown browser context.
This is defense in depth, not a belief that one header solves CSRF. The OWASP CSRF Prevention Cheat Sheet explicitly recommends combining Fetch Metadata with origin verification and other controls appropriate to the application.
Safe GET operations remained side-effect free. Mutations used POST and the BFF header. No authentication state change was hidden behind a link or image-loadable endpoint.
Step 6: rotate refresh state through the BFF
Refresh tokens were already cookies, but their browser-facing contract needed to match the same-origin BFF.
The BFF forwarded only the refresh cookie to the private authentication endpoint. When the upstream API rotated it, the BFF parsed the expected cookie and reissued it as a host-only, HttpOnly, Secure, SameSite=Strict cookie scoped to the authentication route.
The frontend never parsed either token. On page bootstrap it called the same-origin refresh endpoint, received only the safe session projection, and updated UI state from that projection.
Logout did two things even when the upstream response failed:
- attempted to revoke the server session using the refresh cookie;
- expired the BFF session cookie locally.
An upstream 401 on an authenticated member route also cleared the BFF cookie. Keeping a rejected local session would otherwise create repeated unauthorized requests and confusing UI state.
Step 7: keep secrets out of source control and build output
The keyring existed only as a runtime secret. The repository contained the variable name and an example contract, never a real key. Production builds did not receive the value as a public environment variable.
Important separation:
Public build setting
-> may be embedded into browser assets
Server runtime secret
-> available only to the BFF process
-> never prefixed or exposed as public configurationThe rollout also required all BFF replicas to share the active keyring. A new replica with a different key would reject sessions created by another replica. Key rotation therefore belonged to deployment coordination, not an ad-hoc environment edit.
Step 8: test the boundary as an attacker would
The focused tests proved:
- sealing and opening a valid session;
- rejection after one ciphertext byte changed;
- rejection after expiration;
- support for old keys during rotation;
- raw access token absent from the browser JSON response;
- cookie marked
HttpOnlyandSameSite=Strict; - server-side
Authorizationinjection; - no forwarding of the protected session cookie;
- cross-site mutation rejected before
fetch; - oversized and non-JSON bodies rejected;
- route outside the allowlist rejected;
- logout and upstream 401 clearing the local session.
Build and browser-level checks then confirmed that event registration still worked without any client API accepting an access-token parameter.
What this architecture protects, and what it does not
The change substantially reduces credential exfiltration risk. A script can no longer read a bearer token and reuse it from another machine. Tokens are not accidentally copied into frontend state inspection, local storage, error reporting, or analytics payloads.
It does not make cross-site scripting harmless. Malicious same-origin script can still act through the current browser session. Content Security Policy, dependency control, output encoding, short session lifetimes, reauthentication for highly sensitive operations, and narrow BFF routes remain necessary.
It also does not encrypt submitted answers in the database. That is a separate trust boundary. The next article addresses it directly: Encrypting Dynamic Form Answers with KMS Envelope Encryption.
This article intentionally omits organization names, domains, credentials, infrastructure addresses, and identifying production details. The architecture and lessons remain real.