Home/Learn/React/Authentication in a React App

Authentication in a React App

Advanced
Data

Where the token lives, how the app knows who you are before the first render, and what happens on a 401. The security decisions here outweigh the React ones.

Overview

Auth is where the frontend and backend meet, and it is a favourite interview topic because the trade-offs are real. The first decision — an httpOnly cookie or a token held in JavaScript — is a security decision the React code then has to live with. After that come the React-shaped problems: avoiding a flash of the signed-out UI while the session is being checked, refreshing an expired token without ten parallel requests all triggering their own refresh, and clearing every trace of the previous user on sign-out.

Where the Session Lives

Two designs, and the consequences that follow from each.

Cookie vs in-memory token
// Option A — httpOnly cookie set by the server (the stronger default)
//   + JavaScript cannot read it, so an XSS cannot steal it
//   - needs SameSite and CSRF protection
//   - needs credentials: 'include' for cross-origin calls
fetch('/api/me', { credentials: 'include' })

// Option B — access token in memory, refresh token in an httpOnly cookie
//   + works cleanly across separate API domains and mobile clients
//   + the access token is never persisted, so a reload drops it
//   - more moving parts

// Anti-pattern: the token in localStorage. Any script on the page can
// read it, so one XSS — including one inside a dependency — hands over
// every session.

// And never decode a JWT on the client and trust it for authorisation.
// Read it for display (a name, an expiry) only; the server decides
// what the user is allowed to do.

Three States, Not Two

The third state is what prevents a flash of the signed-out UI on every reload.

checking, signed-in, signed-out
// Wrong: user is either present or absent, so on the first render
// it is null and the app renders the sign-in page for a moment
// before the session check returns.

const AuthContext = createContext(null)

export function AuthProvider({ children }) {
  const { data: user, isLoading } = useQuery({
    queryKey: ['session'],
    queryFn: getMe,             // 401 -> null, not an error
    retry: false,
    staleTime: Infinity,
  })

  if (isLoading) return <FullPageSpinner />       // the third state

  return (
    <AuthContext.Provider value={{ user, isAuthenticated: !!user }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
  return ctx
}

// checking | signed-in | signed-out. Rendering anything conditional
// on auth before the check completes is what causes the flash.

401 Handling and Sign-Out

One refresh for many concurrent failures, and a genuinely clean sign-out.

Share one refresh; clear the cache on sign-out
// Ten requests fail with 401 at once. Without care, ten refreshes.
let refreshing = null

async function apiFetch(url, options = {}) {
  let res = await fetch(url, { ...options, credentials: 'include' })

  if (res.status === 401 && !options._retried) {
    refreshing ??= refresh().finally(() => { refreshing = null })  // share one
    const ok = await refreshing
    if (!ok) { redirectToLogin(); throw new AuthError() }
    res = await apiFetch(url, { ...options, _retried: true })
  }
  return res
}

// Sign-out must clear the cache, or the next user sees the previous
// user's data on screen before the refetch lands.
async function signOut() {
  await post('/api/logout')          // server clears the cookie
  queryClient.clear()                // drop every cached response
  navigate('/login', { replace: true })
}

// Also worth handling: a session that expires in another tab.
// The storage event or a periodic session check keeps tabs in sync.

Key Points to Remember

  • 1An httpOnly cookie cannot be read by JavaScript and is the stronger default; localStorage exposes the token to any XSS
  • 2Never trust a client-decoded JWT for authorisation — the server decides what a user may do
  • 3Model auth as checking, signed-in and signed-out, or the app flashes the signed-out UI on every reload
  • 4Concurrent 401s must share a single in-flight refresh rather than each triggering their own
  • 5Sign-out has to clear the query cache, or the next user briefly sees the previous user's data

Interview Questions

Sign in to ask Aria
1

Where should an auth token be stored in a browser app, and why?

Hard
2

How do you avoid a flash of the signed-out UI while the session is being verified?

Medium
3

Ten requests fail with 401 simultaneously. How do you avoid ten refresh calls?

Hard

Ask Aria about Authentication in a React App

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…