# Auth across the stack

> Wire sign-in through React and the API: session cookies, a session query, protected routes, global 401 handling, CSRF, cross-origin setups and social login.

Full-Stack Developer roadmap · Connect them · 18 min read · https://levelcode.org/roadmaps/full-stack/auth-across-the-stack

The [Node authentication lesson](https://levelcode.org/roadmaps/node-backend/auth-node) built the server half: password hashing, session tokens in `HttpOnly` cookies and authorisation checks. A real product also needs the other half: the React app must know who's signed in, show the right screens, send people to the login page when a session expires, and do all of it without weakening the server's protections. This lesson connects the two, covers what changes when the frontend and API live on different origins, and explains how "Sign in with Google" fits in.

## The model: the browser holds a cookie, the app asks who it is

With session cookies, the React app never sees or stores a token. The browser stores the `HttpOnly` session cookie and attaches it automatically to every same-origin request. The app learns who's signed in by **asking the API**:

```sequence
title: Signing in and loading the session
participants: React app, Browser, API
React app -> API: POST /api/auth/login {email, password}
API --> Browser: 204 + Set-Cookie: session=... HttpOnly
> JavaScript can't read the cookie. The browser keeps it.
React app -> API: GET /api/auth/session
Browser -> Browser: Attach the session cookie
API --> React app: 200 { user: { id, email, role } }
React app -> React app: Render signed-in UI
```

The session endpoint returns the current user, or `null`, with status 200 either way, because "nobody is signed in" is a normal state for a public page, not an error:

```ts
// apps/api/src/auth/routes.ts (addition)
.get('/session', async (c) => {
  const token = getCookie(c, 'session')
  const user = token ? await userForSession(token) : undefined
  return c.json({ user: user ?? null }, 200)
})
```

Keeping tokens out of JavaScript entirely is the point. A token in `localStorage` can be read by any script running on your page, so one cross-site scripting bug, or one compromised npm package in your bundle, hands over every user's session. An `HttpOnly` cookie can't be read by scripts at all.

## The session in React

Treat the session as server state, like any other query, with TanStack Query:

```ts
// apps/web/src/auth/session.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api, unwrap } from '../api.ts'

export const sessionKey = ['session'] as const

export function useSession() {
  return useQuery({
    queryKey: sessionKey,
    queryFn: async () => (await unwrap(await api.api.auth.session.$get())).user,
    staleTime: 5 * 60 * 1000,
  })
}

export function useLogin() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async (input: { email: string; password: string }) =>
      unwrap(await api.api.auth.login.$post({ json: input })),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: sessionKey }),
  })
}

export function useLogout() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async () => unwrap(await api.api.auth.logout.$post()),
    onSuccess: () => {
      queryClient.clear()                       // drop every cached query: it belonged to the old user
      queryClient.setQueryData(sessionKey, null)
    },
  })
}
```

Components read `useSession().data` to show the user's name or a "Sign in" link. After login, invalidating the session query refetches it, and every component using it updates.

On logout, `queryClient.clear()` matters more than it looks: without it, the next person to sign in on a shared computer could briefly see the previous user's cached orders.

## Protecting routes

Pages that need a signed-in user should redirect to the login page, remembering where the user was going. With React Router, a layout route's loader can check the session before any protected page renders:

```ts
// apps/web/src/routes/require-user.ts
import { redirect, type LoaderFunctionArgs } from 'react-router'
import { queryClient } from '../query-client.ts'
import { api, unwrap } from '../api.ts'
import { sessionKey } from '../auth/session.ts'

export async function requireUser({ request }: LoaderFunctionArgs) {
  const user = await queryClient.ensureQueryData({
    queryKey: sessionKey,
    queryFn: async () => (await unwrap(await api.api.auth.session.$get())).user,
  })
  if (!user) {
    const next = new URL(request.url).pathname
    throw redirect(`/login?next=${encodeURIComponent(next)}`)
  }
  return user
}
```

```ts
const router = createBrowserRouter([
  { path: '/login', Component: LoginPage },
  {
    Component: AppLayout,
    loader: requireUser,                 // guards every child route
    children: [
      { path: '/orders', Component: OrdersPage },
      { path: '/account', Component: AccountPage },
    ],
  },
])
```

`ensureQueryData` reuses the cached session when there is one, so navigating between protected pages doesn't call the API every time. After a successful login, read `next` from the URL and navigate there, but only if it's a **relative path** starting with `/` (and not `//`): otherwise an attacker can craft a login link that sends users to their own site afterwards, an **open redirect**.

> [!IMPORTANT]
> Route guards and hidden buttons are user experience, not security. Anyone can call your API directly with any request. Every endpoint must check authentication and authorisation itself, exactly as the Node lesson did.

## When the session expires: global 401 handling

Sessions end: they expire, the user logs out in another tab, an admin revokes them. The next API call then returns 401. Instead of handling that in every component, handle it once, in the query client:

```ts
// apps/web/src/query-client.ts
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
import { ApiError } from './api.ts'

function onApiError(error: unknown) {
  if (error instanceof ApiError && error.status === 401) {
    queryClient.setQueryData(['session'], null)
    const next = encodeURIComponent(window.location.pathname)
    window.location.assign(`/login?next=${next}&expired=1`)
  }
}

export const queryClient = new QueryClient({
  queryCache: new QueryCache({ onError: onApiError }),
  mutationCache: new MutationCache({ onError: onApiError }),
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => !(error instanceof ApiError && error.status < 500) && failureCount < 2,
    },
  },
})
```

The `retry` option stops TanStack Query from retrying 4xx errors (a 401 or 404 won't change on retry) while still retrying the occasional 5xx or network failure. The login page can show "Your session expired, please sign in again" when it sees `expired=1`.

## CSRF, from the frontend's side

Cookies are attached to requests automatically, including requests triggered by *other* websites. The server-side defences from the Node lesson cover this: `SameSite=Lax` cookies, no state changes on GET, and an origin check with Hono's `csrf` middleware. The frontend's job is simply not to undermine them:

- Send state-changing requests with `POST`, `PUT`, `PATCH` or `DELETE`, never `GET`.
- Send JSON bodies with `Content-Type: application/json`. A plain HTML form on another site can't send that content type without a CORS preflight, which your API won't approve.
- Don't loosen cookie settings to `SameSite=None` unless you truly need cross-site requests (next section).

## When the frontend and API are on different origins

Same-origin (the setup from the architecture lesson) avoids all of this, so prefer it. But sometimes the app is on `app.example.com` and the API on `api.example.com`. Then:

**CORS** must allow the app's origin, with credentials:

```ts
import { cors } from 'hono/cors'

app.use('/api/*', cors({ origin: ['https://app.example.com'], credentials: true }))
```

**Fetch** must opt in to sending cookies cross-origin:

```ts
export const api = hc<AppType>('https://api.example.com/', { init: { credentials: 'include' } })
```

**Cookies** depend on whether the two origins are the same *site*. `app.example.com` and `api.example.com` share the registrable domain `example.com`, so they're **same-site**: a `SameSite=Lax` cookie still works, as long as it's set by the API and the requests are made with `credentials: 'include'`. Completely different domains (`shop-app.com` calling `shop-api.net`) are **cross-site**: the cookie needs `SameSite=None; Secure`, which weakens CSRF protection, and browsers increasingly block such third-party cookies altogether. If you find yourself there, put both behind one domain instead.

| Setup | CORS needed | Cookie setting | Recommendation |
|---|---|---|---|
| Same origin (`example.com` and `example.com/api`) | No | `SameSite=Lax` | Best |
| Same site, different subdomains | Yes, with credentials | `SameSite=Lax` | Fine |
| Different sites | Yes, with credentials | `SameSite=None; Secure` | Avoid; browsers block third-party cookies |

## Sign in with Google (and friends)

Social login uses **OpenID Connect**, built on OAuth 2.0. The secure pattern for a web app with its own backend is the **authorization code flow with PKCE, handled by the backend**:

```sequence
title: Sign in with Google
participants: Browser, API, Google
Browser -> API: GET /api/auth/google
API --> Browser: 302 to Google with client_id, state, code_challenge
> state and the PKCE verifier are stored in a short-lived cookie.
Browser -> Google: User signs in and consents
Google --> Browser: 302 to /api/auth/google/callback?code=...&state=...
Browser -> API: GET /api/auth/google/callback
API -> Google: Exchange code + code_verifier for tokens
Google --> API: ID token (who the user is)
API -> API: Verify ID token, find or create user, create session
API --> Browser: 302 to /orders + Set-Cookie: session=...
```

The browser never handles Google's tokens: the API exchanges the code server-side, verifies the ID token, then creates **your own** session exactly as password login does. The `state` parameter stops attackers from injecting their own login into a victim's browser, and PKCE stops a stolen authorization code from being usable. Link accounts by the provider's stable user ID (`sub`), and only trust the email address if the provider says it's verified.

Use a maintained library (for example `arctic` or `openid-client`) or a managed identity service for this rather than writing it from the specification: the steps are simple to describe and easy to get subtly wrong.

## Practice

Add sign-in to the `shop` app:

1. Add `GET /api/auth/session` to the API, and mount the auth routes from the Node roadmap under `/api/auth`.
2. Create `useSession`, `useLogin` and `useLogout`, and a header that shows the user's email and a "Sign out" button, or a "Sign in" link.
3. Build a login page that posts the form, shows "Wrong email or password" on 401, and on success navigates to the `next` parameter, only if it's a safe relative path, defaulting to `/orders`.
4. Guard `/orders` and `/account` with `requireUser`, and add global 401 handling.
5. Test: sign in, open `/orders`; delete the session row in the database (or call logout in another tab), then trigger a refetch, and check you land on the login page with an "expired" message.

**Solution**

```ts
// apps/web/src/auth/safe-next.ts
export function safeNext(value: string | null, fallback = '/orders'): string {
  if (!value) return fallback
  // Only same-site relative paths: "/orders" yes; "//evil.com", "https://evil.com", "javascript:..." no.
  if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return fallback
  return value
}
```

```tsx
// apps/web/src/routes/login.tsx
import { useNavigate, useSearchParams } from 'react-router'
import { ApiError } from '../api.ts'
import { useLogin } from '../auth/session.ts'
import { safeNext } from '../auth/safe-next.ts'

export function LoginPage() {
  const [params] = useSearchParams()
  const navigate = useNavigate()
  const login = useLogin()

  const error =
    login.error instanceof ApiError && login.error.status === 401
      ? 'Wrong email or password.'
      : login.error
        ? 'Something went wrong. Please try again.'
        : null

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const data = new FormData(e.currentTarget)
        login.mutate(
          { email: String(data.get('email')), password: String(data.get('password')) },
          { onSuccess: () => navigate(safeNext(params.get('next')), { replace: true }) },
        )
      }}
    >
      {params.get('expired') && <p role="status">Your session expired. Please sign in again.</p>}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" autoComplete="current-password" required />
      {error && <p role="alert">{error}</p>}
      <button disabled={login.isPending}>{login.isPending ? 'Signing in…' : 'Sign in'}</button>
    </form>
  )
}
```

```tsx
// apps/web/src/components/header.tsx
import { Link } from 'react-router'
import { useLogout, useSession } from '../auth/session.ts'

export function Header() {
  const { data: user } = useSession()
  const logout = useLogout()
  return (
    <header>
      <Link to="/">Shop</Link>
      {user ? (
        <>
          <span>{user.email}</span>
          <button onClick={() => logout.mutate(undefined, { onSuccess: () => window.location.assign('/') })}>Sign out</button>
        </>
      ) : (
        <Link to="/login">Sign in</Link>
      )}
    </header>
  )
}
```

`safeNext` is the open-redirect guard: `?next=//evil.com/phish` would otherwise send the freshly signed-in user to an attacker's lookalike page. After logout, a full page load to `/` (`window.location.assign`) is a deliberately blunt reset: every piece of in-memory state from the old session disappears, not just the query cache. In step 5, the refetch gets a 401, the query cache's `onError` clears the session and redirects to `/login?next=/orders&expired=1`, and the login page shows the expiry message.

## Key takeaways

- With session cookies, JavaScript never touches the token; the app asks `GET /session` who's signed in and treats the answer as server state.
- Invalidate the session after login, and clear the whole query cache on logout so no data from the previous user lingers.
- Guard routes in a layout loader with a safe `next` redirect, but rely on the API for real authorisation.
- Handle 401s once in the query client, and don't retry 4xx errors.
- Prefer same-origin; across subdomains use CORS with credentials, and avoid cross-site cookies. Implement social login as a backend-handled authorization code flow with PKCE that ends in your own session.

## Quiz

1. Why does GET /api/auth/session return 200 with { user: null } instead of 401 when nobody is signed in?

   A. 401 can't have a body
   B. Being signed out is a normal state for public pages, so it shouldn't trigger error handling or the global 401 redirect
   C. Browsers block 401 responses
   D. It makes the cookie expire

   **Answer: B.** A 401 here would flow into the global error handler and redirect visitors who simply haven't signed in. Returning null keeps 'not signed in' as ordinary data.

2. After login, the app navigates to the next query parameter. Why validate that it's a relative path like /orders?

   A. Absolute URLs are slower
   B. React Router can't navigate to absolute URLs
   C. Otherwise a crafted login link could send the user to an attacker's site after a genuine sign-in (an open redirect)
   D. To keep the URL short

   **Answer: C.** Users trust a redirect that happens right after logging in on your real site. Allowing only same-site relative paths closes the hole.

3. Why is storing the session token in localStorage riskier than an HttpOnly cookie?

   A. Any script on the page, including injected or compromised third-party code, can read localStorage and steal the token
   B. localStorage is cleared on every page load
   C. localStorage only works over HTTP
   D. Cookies are encrypted by the browser

   **Answer: A.** HttpOnly cookies are invisible to JavaScript, so an XSS bug can't exfiltrate the session. localStorage offers no such protection.

4. The app is on app.example.com and the API on api.example.com. What's needed for session cookies to work?

   A. Nothing; they're the same origin
   B. SameSite=None on the cookie and no CORS
   C. Moving the API to a different domain
   D. CORS allowing app.example.com with credentials, and fetch with credentials: 'include'; SameSite=Lax still works because they're the same site

   **Answer: D.** Different subdomains are different origins (so CORS with credentials is required) but the same site, so Lax cookies are still sent.
