Engineering

Client Auth and Security

The browser is an untrusted client — and you already know what that means

Learning Objectives

By the end of this module you will be able to:

  • Explain the XSS and CSRF attack vectors for each token storage strategy and justify the hybrid in-memory/HttpOnly-cookie approach.
  • Describe the OAuth 2.1 Authorization Code + PKCE flow and explain what vulnerability PKCE prevents.
  • Configure CORS correctly for credentialed requests without using wildcards.
  • Implement CSRF protection using SameSite cookies and explain when additional token-based protection is needed.
  • Identify the correct placement of authentication enforcement in an SSR framework (not middleware).
  • Explain why client-side authorization checks cannot be trusted and what must happen server-side.

Core Concepts

The browser is an untrusted client

You know the rule: the client is not trusted. In server-to-server communication that instinct is automatic. In the browser, it is easy to forget — because you are the one writing both sides. The browser feels like "your" code. It isn't. Any JavaScript running in the browser context, including scripts injected by an attacker, runs with the same privileges as your own code. There is no sandbox from your app's perspective.

This has a direct consequence: client-side authorization checks are decoration, not enforcement. Hiding a button does not prevent the underlying API call. Checking a JWT claim in a React component does not enforce access control. Every access decision must be validated server-side, for every request, independently of any previous authorization state.

The invariant

Permissions must be validated on the server side for every request, independent of previous authorization decisions. The client is display logic only.

The token storage triangle

Where you store a token determines its attack surface. There are three primary locations, each with a different threat model:

localStorage is partitioned by origin, so tokens persist across tabs and survive browser restarts. The cost is that any JavaScript running in the page context — including injected scripts from an XSS attack — can call localStorage.getItem() and steal the token outright. localStorage is fundamentally vulnerable to XSS: no additional technique is required for exfiltration.

sessionStorage is partitioned by tab. Tokens survive navigation but are lost when the tab closes. A new tab cannot access the tokens, which means users must re-authenticate in every new tab unless you have implemented cross-tab communication via a channel like BroadcastChannel.

In-memory (JavaScript variable or module state) is the most XSS-resistant location for access tokens. An attacker who can execute arbitrary JavaScript can still access the value through DOM manipulation, but cannot steal it via localStorage.getItem(). The tradeoff is that the token is lost on page refresh, requiring automatic silent refresh on load.

HttpOnly cookies cannot be read by JavaScript at all. XSS attacks cannot exfiltrate them through DOM access. However, because browsers send cookies automatically with every matching request, they expose a new attack surface: Cross-Site Request Forgery.

Fig 1
Storage XSS risk CSRF risk UX localStorage High None Persistent sessionStorage High None Per-tab only In-memory Lower None Lost on refresh HttpOnly cookie None High Automatic Hybrid (access+refresh) Low Manageable Good with SRR
Token storage tradeoffs by attack surface

The hybrid approach

The production-standard solution is the hybrid approach: access tokens in memory, refresh tokens in HttpOnly cookies. The access token is short-lived (5–15 minutes) and never touches persistent storage. The refresh token, stored in an HttpOnly cookie with Secure and SameSite flags, cannot be stolen via XSS. On page load, the application silently requests a new access token from a refresh endpoint — the browser sends the cookie automatically, and the server returns a new short-lived token.

The combination of short access token lifetimes and HttpOnly refresh token cookies limits exposure significantly: an XSS attacker who can intercept an in-memory token only has a short window. Stealing the refresh token requires a server-side vulnerability, not just injected JavaScript.

Rotating refresh tokens add a further layer: each refresh issues a new token and invalidates the old one. If a stolen token is used, the legitimate session's next refresh will fail — enabling theft detection via token family invalidation.

CORS: the mechanism, not the cargo cult

CORS is frequently configured by trial and error until the browser stops complaining. Understanding the mechanics makes configuration intentional.

CORS is a browser-enforced policy. Servers do not enforce it — they declare their intent through response headers, and the browser decides whether to expose the response to JavaScript. Non-browser HTTP clients are unaffected.

A preflight request is an OPTIONS request the browser sends before a request that is not "simple" (e.g., it uses a non-standard method, custom headers, or a content type other than application/x-www-form-urlencoded/multipart/form-data/text/plain). The server must respond with Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers before the browser will send the actual request.

For credentialed requests (requests that include cookies), two constraints apply simultaneously:

  1. Access-Control-Allow-Credentials: true must be set.
  2. Access-Control-Allow-Origin cannot be a wildcard (*). It must be an explicit origin.

The CORS specification explicitly forbids combining wildcard Access-Control-Allow-Origin with Access-Control-Allow-Credentials, and browsers enforce this at the protocol level.

When you need to support multiple origins dynamically (e.g., in a multi-tenant environment), you must maintain an explicit allowlist and reflect the incoming Origin header only if it matches. Dynamic reflection without allowlist validation effectively grants CORS access to any origin.

Preflight is not CSRF protection

CORS preflight blocks non-simple cross-origin requests. But simple requests (GET, POST with standard content types) bypass preflight and can still be CSRF vectors. Do not rely on CORS configuration for CSRF protection.

CSRF: automatic cookies are the threat model

CSRF works because browsers send cookies automatically with every matching request, regardless of which page triggered the request. A malicious page at evil.com can submit a form that targets bank.com — if the user is logged into bank.com via cookie, the request carries valid credentials.

The defenses, in order of strength:

SameSite cookies tell the browser not to send the cookie on cross-site requests. SameSite=Strict provides the strongest protection but breaks navigation flows where users expect to remain logged in after clicking a link from another site. SameSite=Lax is the practical default: it allows the cookie on top-level navigations (link clicks) but blocks it on embedded resources and AJAX requests from other origins. SameSite alone is strong, but not a complete standalone defense — combine it with at least one additional mechanism.

CSRF tokens (Synchronizer Token Pattern) generate a unique per-session token stored server-side. The server requires this token on every state-changing request. A cross-site page cannot read the token (Same-Origin Policy), so it cannot forge a valid request. Embedding CSRF tokens in custom HTTP headers is more secure than form fields, because custom headers cannot be sent in simple cross-origin requests without preflight.

Fetch Metadata headers (Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest) are sent automatically by modern browsers and cannot be forged by JavaScript. A server can inspect Sec-Fetch-Site: cross-site to reject requests that did not originate from its own origin. This is a modern, token-free alternative to CSRF tokens.

Bearer tokens avoid CSRF entirely

Tokens in the Authorization header are CSRF-resistant because browsers do not automatically send headers in cross-origin requests. If you control the full API design (no cookie-based auth), Bearer tokens with CORS simplify security considerably.

OAuth 2.1 Authorization Code + PKCE

The Implicit flow — which delivered tokens directly in the redirect URI fragment — is deprecated. It exposed access tokens in browser history, referrer headers, and server logs. The replacement is Authorization Code + PKCE, mandatory in OAuth 2.1 for all client types including SPAs.

The flow:

  1. The client generates a random code_verifier (a high-entropy secret, 43–128 characters).
  2. It computes code_challenge = BASE64URL(SHA256(code_verifier)).
  3. The authorization request includes code_challenge and code_challenge_method=S256.
  4. The authorization server issues an authorization code and stores the challenge.
  5. The client exchanges the code for tokens by sending the code_verifier. The server verifies that SHA256(code_verifier) matches the stored challenge.

PKCE prevents authorization code interception: if an attacker intercepts the code (e.g., via a malicious app registered with the same redirect URI scheme), they cannot exchange it for tokens without the code_verifier, which was never transmitted.

The state parameter addresses a different threat: CSRF within the OAuth flow itself. Without state, an attacker can initiate an authorization request and then trick a victim into completing it, hijacking their session. state binds the authorization request to the initiating user session.

state and PKCE are not redundant

state prevents CSRF attacks within the OAuth flow. PKCE prevents authorization code interception. Both should be implemented; they address different attack vectors.

The redirect URI must be validated with exact string matching against pre-registered URIs. Any mismatch should be rejected — partial matching or open redirects are a common vulnerability vector.

Auth in SSR frameworks: middleware is not a security boundary

SSR frameworks like Next.js and SvelteKit provide middleware — code that runs before routes are rendered. It is tempting to put authentication checks here because it is a convenient chokepoint. This is incorrect. Middleware should be used for redirecting unauthenticated users before rendering (improving efficiency and UX), but authentication enforcement must happen in:

  • Route Handlers (API routes) — where data access occurs.
  • Server Actions — where mutations occur.
  • The Data Access Layer — where queries are issued.

The reason is architectural: middleware operates at the edge of the framework's request pipeline, but it does not enforce the boundary of your data. If a request path bypasses middleware (through direct service calls, framework bugs, or misconfiguration), there is no secondary check.

Server components have a meaningful security property: they run server-side only and never expose their computation to the client bundle. Session data, tokens, and authorization state accessed in server components do not travel to the browser. This is materially different from doing the same in a client component, where the rendered output and any embedded data becomes part of the page payload.

Cookies flow automatically from the browser to SSR environments. Bearer tokens must be manually attached. For non-browser clients, Bearer tokens are generally the better choice — cookies are complex for multi-client architectures.

The BFF pattern as the strongest security model

The Backend for Frontend pattern takes the hybrid approach to its logical conclusion: the backend handles OAuth entirely. The browser never receives OAuth tokens. Instead, the BFF issues a session cookie (HttpOnly, Secure, SameSite) and stores the access and refresh tokens server-side. The frontend calls the BFF; the BFF attaches the appropriate access token to upstream service calls.

The security properties are strong:

  • XSS cannot steal OAuth tokens (they never reach the browser).
  • The session cookie cannot be read by JavaScript.
  • CSRF is mitigated by the SameSite flag, CSRF tokens, or Fetch Metadata on the BFF endpoints.

BFF sessions still need their own expiry discipline: sliding window expiration (reset on activity) combined with an absolute maximum prevents sessions from living forever. Session cookies must be configured with HttpOnly, Secure, and SameSite.


Step-by-Step Procedure

1. Configure the server to issue two tokens on login

  • Issue a short-lived access token (5–15 minutes), returned in the response body.
  • Issue a longer-lived refresh token (7–14 days), set as an HttpOnly cookie with Secure and SameSite=Lax.

2. Store the access token in JavaScript memory only

  • Assign it to a module-level variable or framework state — not localStorage, not sessionStorage.
  • Never write it to any persistent browser storage.

3. Handle page load and refresh

  • On application mount, immediately call the refresh endpoint.
  • The browser sends the HttpOnly cookie automatically; if valid, the server returns a new access token.
  • Store the new access token in memory.
  • If no valid refresh token exists, redirect to login.

4. Proactively refresh before expiry

  • Schedule a refresh call 30–60 seconds before the access token's exp claim.
  • Do not wait for a 401 response to trigger refresh; reactive handling creates race conditions in concurrent requests.

5. Implement an HTTP interceptor for 401 responses (defensive)

  • Despite proactive refresh, network latency and clock skew can cause 401s.
  • Queue pending requests while a refresh is in flight — do not trigger multiple concurrent refreshes.
  • Retry queued requests with the new token on successful refresh; reject on failure.

6. Implement logout and tab coordination

  • On logout, call the server to invalidate the refresh token.
  • Clear the in-memory access token.
  • Use BroadcastChannel to notify other open tabs of the logout event.

Decision point: If users consistently lose sessions on page refresh and your threat model does not require strict XSS resistance for the access token, consider whether the BFF pattern fits better — moving token management entirely to the server.


Configuring CORS for credentialed requests

1. Maintain an explicit origin allowlist on the server

ALLOWED_ORIGINS = ["https://app.example.com", "https://admin.example.com"]

2. On each request, inspect the Origin header

  • If Origin matches an entry in the allowlist, set Access-Control-Allow-Origin to that value exactly.
  • If Origin is not in the allowlist, either omit the header or return an error.
  • Never reflect Origin unconditionally.

3. Set credentials header when using cookies

Access-Control-Allow-Credentials: true

4. Never combine wildcard with credentials

  • Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true is invalid.
  • Browsers will block the response. This is not a configuration gotcha — it is intentional.

5. Configure preflight responses

  • Respond to OPTIONS with Access-Control-Allow-Methods and Access-Control-Allow-Headers.
  • Set Access-Control-Max-Age to reduce preflight frequency in production.

Decision point: If your API uses Bearer tokens rather than cookies, you do not need Access-Control-Allow-Credentials. This simplifies your CORS configuration and removes CSRF from the cookie threat model.


Common Misconceptions

"I'm using HTTPS, so I don't need to worry about token theft." HTTPS protects data in transit. It does not prevent XSS from reading localStorage, does not stop CSRF, and does not protect tokens stored in browser-accessible locations. HTTPS is necessary but not sufficient.

"My middleware checks auth, so my routes are protected." Middleware in Next.js, SvelteKit, and similar frameworks is an efficiency layer, not a security boundary. Actual enforcement must happen in Route Handlers, Server Actions, and the data access layer, where the actual data is fetched or mutated. Middleware can redirect unauthenticated users early — but that is a UX optimization, not an enforcement mechanism.

"I'm hiding the admin panel, so unauthorized users can't reach it." Client-side route guards and conditional rendering prevent users from seeing the UI, but they do not prevent direct API calls. The API must enforce authorization on every request. The underlying claim is clear: client-side authorization cannot be trusted to enforce access control.

"I can decode the JWT to see if the user has a permission, and trust that." JWT payloads are base64-encoded but not encrypted. Decoding them is trivial and available to anyone. Client-side JWT validation is limited to format and expiry checks. Server-side validation must verify the signature and enforce authorization decisions. A client-side check on a decoded JWT claim can tell you what the token says — it cannot tell you whether the token was tampered with (without a signature check), whether it has been revoked, or whether the server agrees.

"SameSite=Strict gives full CSRF protection." SameSite=Strict prevents the cookie from being sent on all cross-site requests, including top-level navigations. This breaks expected behavior when users arrive from external links and expect to be logged in. More importantly, SameSite alone is not a complete standalone defense — combine it with CSRF tokens or Fetch Metadata.

"The wildcard CORS setting is fine for development and I'll tighten it later." Wildcard CORS with credentials is invalid and browsers reject it. Wildcard CORS without credentials is permissive and moves the security boundary from the browser to network perimeter alone. Tighten origins during development — the habit matters.

"PKCE is only needed for mobile apps, not SPAs." PKCE is mandatory for all OAuth 2.1 clients. It is not a mobile-specific concern; authorization code interception is a viable attack in SPA contexts as well. The Implicit flow, which was the SPA-specific solution, was deprecated precisely because of its inherent token exposure.


Worked Example

Scenario: SPA with OAuth login and a Go backend

A React SPA authenticates users via an identity provider (e.g., Auth0 or Keycloak). The backend is a Go API. The goal is to implement secure authentication using Authorization Code + PKCE, the hybrid token storage approach, and proper CORS.

Frontend: initiating the OAuth flow

// Generate PKCE parameters before redirecting
async function initiateLogin() {
  const codeVerifier = generateRandomString(96); // high-entropy, 43-128 chars
  const codeChallenge = await sha256base64url(codeVerifier);
  const state = generateRandomString(32);

  // Store verifier and state for validation on callback
  sessionStorage.setItem('pkce_code_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid profile',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  window.location.href = `${AUTH_SERVER}/authorize?${params}`;
}

Frontend: handling the callback

async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const returnedState = params.get('state');

  // Validate state to prevent CSRF in the OAuth flow
  const storedState = sessionStorage.getItem('oauth_state');
  if (returnedState !== storedState) throw new Error('State mismatch');

  const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
  sessionStorage.removeItem('pkce_code_verifier');
  sessionStorage.removeItem('oauth_state');

  // Exchange code for tokens via your backend, not directly
  // The backend handles the token exchange and sets the HttpOnly cookie
  const res = await fetch('/api/auth/callback', {
    method: 'POST',
    credentials: 'include', // send/receive cookies
    body: JSON.stringify({ code, code_verifier: codeVerifier }),
    headers: { 'Content-Type': 'application/json' },
  });

  const { access_token } = await res.json();
  // Store access token in memory only
  setAccessToken(access_token);
}

Backend (Go): token exchange endpoint

The backend receives the code and verifier, exchanges them with the identity provider, and sets the refresh token as an HttpOnly cookie.

func handleAuthCallback(w http.ResponseWriter, r *http.Request) {
    var body struct {
        Code         string `json:"code"`
        CodeVerifier string `json:"code_verifier"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    tokens, err := exchangeCodeWithIDP(body.Code, body.CodeVerifier)
    if err != nil {
        http.Error(w, "token exchange failed", http.StatusUnauthorized)
        return
    }

    // Refresh token: HttpOnly, Secure, SameSite=Lax
    http.SetCookie(w, &http.Cookie{
        Name:     "refresh_token",
        Value:    tokens.RefreshToken,
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
        MaxAge:   7 * 24 * 3600, // 7 days
        Path:     "/api/auth",   // restrict to refresh endpoint only
    })

    // Return access token in body — stored in memory by the SPA
    json.NewEncoder(w).Encode(map[string]string{
        "access_token": tokens.AccessToken,
    })
}

Backend (Go): CORS middleware

var allowedOrigins = map[string]bool{
    "https://app.example.com": true,
}

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        origin := r.Header.Get("Origin")
        if allowedOrigins[origin] {
            w.Header().Set("Access-Control-Allow-Origin", origin)
            w.Header().Set("Access-Control-Allow-Credentials", "true")
            w.Header().Set("Vary", "Origin") // important for caching
        }

        if r.Method == http.MethodOptions {
            w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
            w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
            w.Header().Set("Access-Control-Max-Age", "86400")
            w.WriteHeader(http.StatusNoContent)
            return
        }

        next.ServeHTTP(w, r)
    })
}

Backend (Go): enforcing auth in a route handler (not middleware)

func getProfileHandler(w http.ResponseWriter, r *http.Request) {
    // Validate the access token on every request
    token := r.Header.Get("Authorization")
    claims, err := validateAccessToken(token)
    if err != nil {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }
    // Now enforce business-level authorization
    if !claims.HasPermission("read:profile") {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }
    // Proceed — fetch only minimum necessary data for this client
    profile := fetchProfile(claims.Subject)
    json.NewEncoder(w).Encode(profile)
}
The route handler validates the token and checks permissions. Middleware redirected the user — but the actual gate is here, at the data boundary.

Quiz

1. A user opens two tabs in your SPA. Both use in-memory access tokens from the hybrid approach. What mechanism do you use to synchronize a logout across both tabs without a server round-trip?

  • A. localStorage event listener
  • B. BroadcastChannel API
  • C. Shared Worker
  • D. sessionStorage

Answer: B. The BroadcastChannel API is the native modern approach for coordinating events like logout across tabs.


2. You configure your API with:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

What happens when a browser makes a credentialed cross-origin fetch?

  • A. The request succeeds because wildcard covers all origins.
  • B. The browser blocks the response; this header combination is invalid.
  • C. The cookie is sent but the response is stripped.
  • D. The server receives the request but returns a 403.

Answer: B. The CORS specification explicitly forbids combining wildcard Access-Control-Allow-Origin with Access-Control-Allow-Credentials. Browsers enforce this at the protocol level and will block the response.


3. In the Authorization Code + PKCE flow, what specific attack does PKCE prevent?

  • A. CSRF in the OAuth flow
  • B. XSS token theft from localStorage
  • C. Authorization code interception
  • D. Redirect URI spoofing

Answer: C. PKCE prevents authorization code interception. If an attacker intercepts the authorization code, they cannot exchange it for tokens without the code_verifier, which is never transmitted during the authorization request. The state parameter addresses CSRF in the flow; these are separate mechanisms.


4. You are reviewing a Next.js application. Authentication checks are implemented only in the middleware file (middleware.ts). It redirects unauthenticated users to /login. Is this secure?

  • A. Yes — middleware runs on every request and is the correct place for auth enforcement.
  • B. No — middleware can be bypassed and is not a security boundary; enforcement must also exist in Route Handlers and Server Actions.
  • C. Yes — if using App Router, middleware is integrated into the framework's security model.
  • D. No — middleware only runs on client-side navigation, not server requests.

Answer: B. Middleware improves efficiency by redirecting early, but actual authentication must be enforced in Route Handlers, Server Actions, and the Data Access Layer. Middleware is not a security boundary.


5. A user's session uses a short-lived JWT access token. The server has no token revocation mechanism. The user's account is suspended. What is the security implication?

  • A. The user is immediately denied access because the server checks account status on each request.
  • B. The user retains access until the token expires (up to 15 minutes for a properly configured system).
  • C. The token is invalidated when the refresh token cookie expires.
  • D. Suspension is propagated via a webhook to invalidate the JWT.

Answer: B. JWTs cannot be revoked on an individual basis once issued. The user retains access until the access token expires. This is a known tradeoff of JWT-based auth: short lifetimes limit the exposure window; opaque tokens with an introspection endpoint provide immediate revocation at the cost of a network call per request.

Key Takeaways

  1. Token storage is a threat model decision. localStorage is XSS-vulnerable. HttpOnly cookies are CSRF-vulnerable. The hybrid approach — access tokens in memory, refresh tokens in HttpOnly cookies — is the widely adopted production standard because it limits each attack vector to a manageable scope.
  2. PKCE is mandatory, not optional. The Implicit OAuth flow is deprecated. Authorization Code + PKCE is the standard for all OAuth 2.1 clients. PKCE prevents authorization code interception; state prevents CSRF within the flow. Both are required; they are not redundant.
  3. Credentialed CORS cannot use wildcards. Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true are mutually exclusive. Use an explicit allowlist and reflect only matching origins. Dynamic reflection without an allowlist is equivalent to no restriction.
  4. Middleware is not a security boundary. In SSR frameworks, middleware redirects unauthenticated users early — it does not enforce authorization. Auth checks must live in Route Handlers, Server Actions, and the data access layer, where data is actually fetched or mutated.
  5. Client-side authorization is decoration. Any check that runs in the browser can be bypassed. Every access decision must be validated on the server for every request. Server components help by keeping sensitive data out of the client bundle, but they do not replace server-side authorization enforcement.

Further Exploration

Standards and Specifications

Security Checklists and Guidance

Framework Documentation

  • Auth.js Documentation — Framework-agnostic auth library with SSR-specific guides for Next.js and SvelteKit