Step 05 of 8
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.
The Node authentication lesson 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:
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:
// 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:
// 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:
// 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
}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.
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:
// 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,PATCHorDELETE, neverGET. - 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=Noneunless 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:
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:
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:
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:
- Add
GET /api/auth/sessionto the API, and mount the auth routes from the Node roadmap under/api/auth. - Create
useSession,useLoginanduseLogout, and a header that shows the user's email and a "Sign out" button, or a "Sign in" link. - Build a login page that posts the form, shows "Wrong email or password" on 401, and on success navigates to the
nextparameter, only if it's a safe relative path, defaulting to/orders. - Guard
/ordersand/accountwithrequireUser, and add global 401 handling. - 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.
Show solution
// 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
}// 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>
)
}// 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 /sessionwho'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
nextredirect, 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.
Check yourself
Quiz
0/4 correct · 4 left
1.Why does GET /api/auth/session return 200 with { user: null } instead of 401 when nobody is signed in?
2.After login, the app navigates to the next query parameter. Why validate that it's a relative path like /orders?
3.Why is storing the session token in localStorage riskier than an HttpOnly cookie?
4.The app is on app.example.com and the API on api.example.com. What's needed for session cookies to work?
Finished reading?
Mark it complete to fill in your roadmap.