Aug 14, 2025
Edit me

Understanding Browser vs. Backend Security in OAuth Flows

Index

  1. BE (SSR) is not always safe — any transaction going through the browser env is risky

  2. Why browser env is risky and how to prevent this

  3. How JWT works — the real long-lived “key” is the session cookie

  4. This is a design pattern, not a law of physics — knowing what to send through the browser and what not to

  5. Other insights and “aha” moments


0. BE (SSR) is not always safe

  • Just because you’re rendering pages on the backend (e.g., Razor) doesn’t mean you’re in a “safe” back-channel.

  • If the request/response passes through the browser, it’s front-channel and inherits browser risks.

  • Example: /auth/callback after B2C login is still a browser redirect to your backend — safe only if it carries a short-lived code, not a token.


1. Why browser env is risky and how to prevent this

Why risky

  • URLs leak — history, referrer headers, server/CDN logs.

  • JavaScript can steal tokens if they’re in localStorage/sessionStorage or DOM (XSS, malicious extensions).

  • Third-party scripts (analytics, chat widgets) can read page context.

  • CSRF — browser auto-sends cookies unless you limit with SameSite.

How to reduce risk

  • Don’t send long-lived tokens to the browser.

    Keep access tokens short-lived (minutes), store refresh tokens server-side only.

  • Use Secure + HttpOnly + SameSite flags on cookies:

    • Secure → Only send over HTTPS.

    • HttpOnly → JS can’t read it.

    • SameSite=Lax or Strict → Mitigates CSRF.

  • Use response_mode=form_post in OIDC if you want to hide the code from URLs.

  • Avoid storing tokens in localStorage or sessionStorage.

  • Use CSRF tokens for state-changing requests.


  • JWTs are signed tokens with an expiration (exp) — they can’t refresh themselves.

  • Refresh tokens allow minting new JWTs without re-login.

  • In a backend-driven flow:

    • Browser: only has a session cookie to your backend.

    • Backend: stores the real long-lived refresh token and short-lived access token from B2C.

    • When access token expires, backend uses refresh token to get a new one — browser never sees it.

  • If the cookie is stolen, attacker can use your backend like the real user until you revoke it — which is why it must be HttpOnly, Secure, SameSite and have proper server-side invalidation.


3. This is a design pattern, not a law of physics

  • Core mindset:

    If something is long-lived and grants access, keep it out of the browser environment.

  • Front-channel (browser involved) → OK for short-lived, sender-bound artifacts (auth code, CSRF token).

  • Back-channel (server↔server only) → safe place for long-lived credentials (refresh tokens, API keys, client secrets).

  • Ask yourself for every endpoint: “Could this be called from a browser page? If yes, it’s front-channel — no long-lived secrets here.”


4. Other insights & “aha” moments

Your main confusion

  • “If the browser has the JWT, isn’t that the same as having a long-lived login?”

  • “If my BE can refresh tokens with the session cookie, isn’t that basically the same as giving the browser a long-lived token?”

The answer that clicked

  • The difference is control & exposure:

    • In your current model, the refresh token is only in the backend.

    • The browser can’t talk to B2C directly to mint new tokens — it can only talk to your BE.

    • You can centrally revoke or expire the session at the backend.

    • If you gave the browser a long-lived token (or refresh token), once stolen, it’s valid until expiry with no easy central kill.


Extra rules to remember

  • Don’t rely on Origin for auth — it’s forgeable outside real browsers. Use it only as extra CSRF signal.

  • 302/303, not 301 for auth redirects — 301 can be cached.

  • B2C’s own cookie is only for B2C’s domain; browsers won’t send it to yours — you must issue your own.

  • Short-lived JWT in browser memory is acceptable for SPA → API patterns if you can’t do backend sessions — but never store long-lived tokens in browser-accessible places.


Quick decision chart

  • Browser sees it? → Must be short-lived & minimal scope.

  • Needs to be long-lived? → Store in backend only.

  • Is it a refresh token or API key? → Backend only.

  • Is it a session cookie? → Must be Secure, HttpOnly, SameSite, and revocable server-side.

1. Flow comparison — risky vs safe

### A) Long-lived token in browser (risky)

User logs in via IdP (B2C)
       |
       v
Browser receives ACCESS TOKEN (JWT) valid for days/weeks
       |
       v
Browser stores token in localStorage/sessionStorage
       |
       v
[Every request to API]
   Browser → API with JWT (Authorization: Bearer ...)
       |
       v
If attacker steals JWT (XSS, extension, copied storage)
   → Can call API directly until token expires
   → No server-side revocation possible without extra infra

B) Short-lived code + backend refresh (safe)

User logs in via IdP (B2C)
       |
       v
Browser redirected to Backend /callback with short-lived CODE
       |
       v
Backend exchanges CODE with B2C /token endpoint
       |
       v
Backend receives:
   - Access token (JWT) - short-lived (minutes/hours)
   - ID token (JWT)
   - Refresh token (long-lived, server-side only)
       |
       v
Backend sets Secure+HttpOnly+SameSite session cookie in browser
       |
       v
[Every request to API]
   Browser → Backend with cookie
       |
   Backend uses access token, or refreshes it with refresh token
       |
       v
If attacker steals cookie:
   → Can call Backend until cookie revoked/expired
   → Cannot call IdP or API directly (no refresh token in browser)

🚨 The session cookie is the actual authentication in your app

  • The cookie is not the Azure access token — it’s just a pointer (session ID or signed blob) to the server’s stored session.

  • The real long-lived credential (refresh token) never leaves your backend.

  • If someone steals this cookie, they can impersonate the session until you revoke it, but they still can’t go directly to B2C to mint new tokens.

  • The browser can’t read or modify it if you set:

    • Secure → HTTPS only

    • HttpOnly → Not accessible to JS (blocks XSS theft)

    • SameSite=Lax or Strict → Prevents cross-site requests from auto-sending it (CSRF protection)

    • Short expiry + sliding session → Limits window if stolen


[Initial Login]
Browser → Backend → B2C /authorize
       |
User authenticates on B2C-hosted UI
       |
B2C → Browser → Backend /callback?code=XYZ
       |
Backend → B2C /token (grant_type=authorization_code)
       |
B2C returns:
   - Access token (JWT, exp ~1h)
   - ID token (JWT, exp ~1h)
   - Refresh token (long-lived)
       |
Backend stores tokens server-side
Backend sets Secure+HttpOnly+SameSite session cookie to browser
[When Access Token Expires]
Browser → Backend (session cookie)
       |
Backend looks up session in store
       |
Backend → B2C /token (grant_type=refresh_token)
       |
B2C returns new tokens
       |
Backend updates session store
       |
Request completes — browser never sees Azure tokens

  • Short-lived pointer to server session, not the credential itself.

  • Revocable instantly by deleting session from server store.

  • Protected from JS by HttpOnly — immune to most XSS exfiltration.

  • Protected from cross-site send by SameSite.

  • Only sent to your domain — not to any 3rd party, not even B2C.