Understanding Browser vs. Backend Security in OAuth Flows
Index
-
BE (SSR) is not always safe — any transaction going through the browser env is risky
-
Why browser env is risky and how to prevent this
-
How JWT works — the real long-lived “key” is the session cookie
-
This is a design pattern, not a law of physics — knowing what to send through the browser and what not to
-
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.
2. How JWT works — the real long-lived “key” is the session cookie
-
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
-
2. How JWT refresh works with session cookie in play
[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
Why the session cookie is safer than a long-lived JWT in the browser
-
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.