The decisions behind auth are more consequential than most developers realize.
In this article we'll cover how token storage works and why the wrong choice creates real security vulnerabilities, how silent refresh keeps users logged in without interrupting their experience, how to handle session expiry gracefully, what actually happens during an OAuth flow, and the authentication decisions most frontend developers get wrong.
Authentication is one of those things that looks simple from the outside. User logs in, you get a token, you attach it to requests. Done.
Except it's not done. Not even close.
I've worked on platforms handling more than 10 million active users across multiple OTT applications. Authentication touches every single one of them. And the decisions you make at the frontend layer - where to store tokens, how to refresh them, how to handle expiry - have consequences that go well beyond a login form.
Get it wrong and you're looking at XSS vulnerabilities, session hijacking or users getting logged out mid-stream during a live event. At scale, any of those is a serious incident.
This article is about getting it right.
Token Storage - The Decision That Has the Most Security Consequences
When you authenticate a user and get back an access token, the first question is: where do you put it?
There are three common options. Each has real tradeoffs.
localStorage
This is the most common choice because it's the easiest. Persist the token, read it back on page load, attach it to requests. Simple.
The problem is XSS. If an attacker manages to inject malicious JavaScript into your page - through a compromised dependency, a third-party script or an unsanitized input - they can read everything in localStorage. Your token is gone. The attacker now has it.
// Any injected script can do this
const token = localStorage.getItem('access_token')
// Token is now in the attacker's hands
At scale, with multiple third-party scripts running on your platform, the XSS surface area is larger than you think.
httpOnly Cookies
The browser sets these cookies automatically on every request to your domain. JavaScript cannot read them. That means an XSS attack cannot steal them.
This sounds like the obvious winner, and for many server-rendered applications it is. But it comes with its own complexity. You need to think about CSRF protection, SameSite cookie attributes and cross-origin request handling. On a platform with multiple subdomains or a separate API domain, cookie configuration gets complicated quickly.
In-Memory Storage
This is the approach I consider most secure for single-page applications. Store the access token in a JavaScript variable, never in localStorage, never in a cookie readable by JavaScript.
let accessToken = null
export function setToken(token) {
accessToken = token
}
export function getToken() {
return accessToken
}
An XSS attack cannot read a JavaScript variable from another script's scope if you're careful about how you expose it. The token lives only in memory and disappears when the tab closes.
The tradeoff is that you lose persistence across page refreshes. Which is exactly why silent refresh exists.
Silent Refresh - Keeping Users Logged In Without Asking Them To
Access tokens are short-lived by design. Typically 15 minutes to an hour. That's intentional - if one gets stolen, the damage window is limited.
But you can't ask a user to log in again every hour. Especially on an OTT platform where they might be mid-stream during a live sports event.
Silent refresh solves this. The idea is straightforward: before the access token expires, use the refresh token to get a new one automatically, without any visible interruption to the user.
Here's the basic pattern:
let refreshTimeout = null
function scheduleTokenRefresh(expiresIn) {
// Refresh 60 seconds before expiry
const refreshIn = (expiresIn - 60) * 1000
refreshTimeout = setTimeout(async () => {
try {
const newToken = await refreshAccessToken()
setToken(newToken.accessToken)
scheduleTokenRefresh(newToken.expiresIn)
} catch (err) {
// Refresh failed, session has expired
handleSessionExpiry()
}
}, refreshIn)
}
A few things worth noting here:
The refresh token should be stored in an httpOnly cookie. It's longer-lived and more sensitive than the access token. You don't want JavaScript touching it directly. The server reads it from the cookie and issues a new access token.
Handle the case where the tab has been in the background. Browsers throttle timers in inactive tabs. When the user comes back to the tab, check if the token has already expired and refresh immediately if so.
Guard against multiple simultaneous refresh calls. On platforms with multiple concurrent API requests, several calls can fail at the same time when a token expires. Without a guard, you'll fire multiple refresh requests simultaneously.
let refreshPromise = null
async function refreshAccessToken() {
if (refreshPromise) {
return refreshPromise
}
refreshPromise = fetch('/auth/refresh', { method: 'POST' })
.then(res => res.json())
.finally(() => {
refreshPromise = null
})
return refreshPromise
}
This ensures only one refresh request goes out regardless of how many callers triggered it.
Session Expiry - Handle It Like It's Part of the Product
Session expiry is inevitable. Refresh tokens expire. Users get logged out on other devices. Admins revoke access. Your job is to handle it in a way that doesn't feel broken.
The worst experience is a silent failure - the user clicks something, nothing happens, no explanation. Or worse, they get a raw 401 error in the UI.
A better approach has three parts:
Intercept 401 responses globally. Don't handle auth errors individually in every API call. Set up a single interceptor that catches 401s, attempts a token refresh, and retries the original request. If the refresh fails, then you handle expiry.
async function apiFetch(url, options) {
let response = await fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${getToken()}`
}
})
if (response.status === 401) {
try {
const newToken = await refreshAccessToken()
setToken(newToken.accessToken)
// Retry original request with new token
response = await fetch(url, {
...options,
headers: {
...options?.headers,
Authorization: `Bearer ${newToken.accessToken}`
}
})
} catch {
handleSessionExpiry()
}
}
return response
}
Tell the user what happened. When the session genuinely expires, show a clear message. "Your session has expired, please log in again." Not a blank screen, not a generic error. A real explanation with a clear action.
Preserve their intent. Store the URL they were trying to access before redirecting to login. After they authenticate, bring them back to where they were. On a content platform this matters - a user who was about to watch something should land back on that content, not the homepage.
OAuth and Social Login - What Actually Happens in the Browser
Most developers use a library for OAuth and treat it as a black box. That's fine until something breaks and then you need to understand what's actually happening.
Here's the simplified flow for a third-party login:
- User clicks "Login with Google."
- Your app redirects the browser to Google's authorization endpoint with your client ID, requested scopes and a redirect URI.
- Google authenticates the user and redirects back to your redirect URI with an authorization code.
- Your frontend sends that code to your backend.
- Your backend exchanges the code for tokens directly with Google using your client secret.
- Your backend issues its own session token to your frontend.
A few things to pay attention to here:
Never exchange the authorization code on the frontend. The code exchange requires your client secret. That should never be in frontend code.
Use the state parameter. This is a random value you generate before the redirect and verify when the user comes back. It protects against CSRF attacks on the OAuth flow.
function initiateOAuthLogin() {
const state = crypto.randomUUID()
sessionStorage.setItem('oauth_state', state)
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
state
})
window.location.href = `https://accounts.google.com/o/oauth2/auth?${params}`
}
function handleOAuthCallback() {
const params = new URLSearchParams(window.location.search)
const returnedState = params.get('state')
const savedState = sessionStorage.getItem('oauth_state')
if (returnedState !== savedState) {
throw new Error('OAuth state mismatch. Possible CSRF attack.')
}
// Safe to proceed
}
Use PKCE for public clients. If you're doing the token exchange from a mobile app or a pure SPA without a backend, PKCE (Proof Key for Code Exchange) replaces the client secret with a cryptographic challenge. It's the current best practice for frontend-only OAuth flows.
The Security Decisions Most Developers Get Wrong
Storing sensitive tokens in localStorage. Covered above, but worth repeating. The convenience is not worth the XSS exposure.
Not setting token expiry short enough. Access tokens should be short-lived. If yours are valid for 24 hours, a stolen token gives an attacker a 24-hour window. Keep them short and lean on silent refresh.
Trusting the frontend for authorization. Hiding a button in the UI is not authorization. The API must enforce access control. Frontend checks are for user experience only, never for security.
Not handling token refresh race conditions. Already covered with the single refresh promise pattern. This one causes subtle bugs that are hard to reproduce and harder to debug in production.
Logging out only on the current device. When a user logs out or changes their password, invalidate their refresh tokens on the server. Otherwise they're still effectively logged in on every other device they've used.
Sending tokens in URL parameters. Tokens in URLs end up in browser history, server logs, and referrer headers. Always send them in the Authorization header or a cookie. Never in the URL.
Final Thoughts
Authentication on the frontend is not just a login form and a token in localStorage. It's a set of decisions that affect the security, reliability, and user experience of your entire platform.
Get the storage right. Build silent refresh properly. Handle expiry gracefully. Understand the OAuth flow well enough to debug it when it breaks.
The developers who handle auth well aren't the ones who memorized every OAuth RFC. They're the ones who understood the tradeoffs and made deliberate decisions at each step.
At scale, deliberate decisions are the only kind that hold up.
Have thoughts or questions on frontend authentication? Drop them in the comments, always happy to discuss.
This article is part of the Frontend at Scale series.
Top comments (0)