Authentication
A signed-in user hits refresh and lands back on the sign-in screen. Their credentials were fine. The app rendered its route guard before it had finished asking whether a session existed, and the guard read the missing answer as "signed out."
Getting that right starts with being precise about what a session is: a piece of state that says someone is signed in and carries their identity, owned in one place so every component reads the same answer. The course builds this out against a CRM project using Supabase, but the shape is the same with any auth provider: Firebase, Auth0, Clerk, or your own backend. This chapter covers that shape.
Session state lives in a provider
Whether a user is signed in matters everywhere: the router needs it to guard pages, the header needs it to show who's logged in, forms need it to know where to send data. That is exactly the situation context exists for, and the standard move is the provider-plus-hook pattern from that chapter: an AuthProvider owns the session state, and a useAuth hook hands it to any component that asks.
The session state has three meaningful values, and the third one is the one people miss:
undefined: the check hasn't finished. The app has loaded and is still asking the provider whether a session exists.null: the check came back, and nobody is signed in.- a session object: someone is signed in, and the object carries their identity.
That third value is what fixes the refresh bounce from the opening. The session still exists in browser storage, but for a moment after the first render the app hasn't read it yet. Starting at undefined lets the app render a brief loading state instead of a wrong answer.
Checking for an existing session and listening for changes both reach outside React, so they belong in an effect that runs once after the first render:
import { createContext, useContext, useState, useEffect } from 'react'
import { auth } from './authClient'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [session, setSession] = useState(undefined)
useEffect(() => {
async function getInitialSession() {
const { data } = await auth.getSession()
setSession(data.session) // the session object, or null
}
getInitialSession()
const {
data: { subscription },
} = auth.onAuthStateChange((_event, session) => {
setSession(session)
})
return () => subscription.unsubscribe()
}, [])
return <AuthContext value={{ session }}>{children}</AuthContext>
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === null) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}Two jobs happen in that effect. getSession does the one-time check: is there a session already sitting in storage from a previous visit? Then onAuthStateChange sets up a listener, a bit like switching on a security camera, that fires whenever the user signs in or out and keeps the state current from then on. Method names vary by provider, but every auth SDK offers both moves: read the current session once, and subscribe to changes.
Watch the ordering
A slow getSession can resolve after the listener has already delivered a newer session and overwrite it with the stale one. Many SDKs fire the listener with the initial session on subscribe, in which case setting state from the listener alone removes the race.
What a JSON Web Token is
One string travels with every request to prove who is asking. Open devtools on a signed-in app, copy that string out of storage, and ask what the person holding it can see. The answer is all of it. Behind the session object sits a JSON Web Token, three dot-separated parts: a header naming the signing algorithm, a payload of claims about the user, and a signature. Each part is base64url-encoded, and encoding is a formatting step rather than encryption:
// a token is three base64url segments joined by dots
const [header, payload, signature] = token.split('.')
// atob reads standard base64, so swap the two URL-safe characters first
JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
// { sub: '9f2c...', email: '[email protected]', exp: 1789450000 }Two statements in a browser console, no key involved. So a JWT payload is readable by anyone holding the token, which makes one rule absolute: secrets never belong in a claim. Standard claims are things like sub for the user's ID, exp for the expiry time, and whatever else the provider chooses to include.
The signature is the part a reader cannot forge. The provider signs the header and payload with a signing key, and on each request the server verifies that signature. Some providers use a single shared secret for both signing and verifying, which is why that secret stays on the server; others sign with a private key and publish a matching public key that any verifier can check against.
Either way, a valid signature proves the token was issued here and has not been altered since. It says nothing about who has read the contents.
The token lives in the browser, in local storage or a cookie, and the client library attaches it to every request automatically. Where it lives is a security decision. Local storage is readable by any JavaScript on the page, so an XSS bug turns into token theft; httpOnly cookies are invisible to scripts, which stops the theft, though a script on the page can still act as the user while it runs, and cookies bring CSRF concerns that need their own mitigation.
Carrying the token on every request is what makes JWT auth stateless: the server keeps no list of who is currently logged in. The signed token itself is the proof, so the user proves who they are on every request without re-sending a password.
Supabase layers a convention on top of that anatomy, and the convention is Supabase's rather than part of the JWT spec: a signed-in user's token carries a role claim reading authenticated, while a request made before anyone signs in gets the anon role.
Those unauthenticated requests travel on a project API key that ships inside your client bundle: the legacy key is called the anon key and is itself a JWT, and the current one is called the publishable key, an sb_publishable_... string that is not a token at all. Shipping it is safe only when Row Level Security is enabled on every table it can reach, and the next section covers those policies. Without them, anyone who opens devtools can read and write those rows.
Keep the service key off the client
The publishable key's counterpart, the service-role or secret key, bypasses every policy, so it must never reach the client bundle or a VITE_-prefixed env var, since those are compiled into the bundle you ship.
Sign up, sign in, sign out
The three flows share one shape: call the provider's SDK method, let the auth listener update the session state, and navigate. Each auth function typically lives in the same provider file as the session state, gets exposed through the context value, and returns a plain result the calling component can act on:
async function signIn(email, password) {
const { data, error } = await auth.signInWithPassword({
email: email.toLowerCase(),
password,
})
if (error) return { success: false, error: error.message }
return { success: true, data }
}On success the provider issues a JWT, the client library stores it and builds the session object, and the onAuthStateChange listener updates the state. The component that called signIn only needs to check the result and navigate:
const { signIn } = useAuth()
async function handleSubmit(email, password) {
const { success, error } = await signIn(email, password)
if (success) navigate('/dashboard')
else setError(error)
}The other two flows are variations. Sign up sends the new user's credentials to the provider, which stores them and, with most providers, signs the user in at the same time, so the same navigation logic applies. Sign out calls the SDK's sign-out method, which clears the stored token; the listener sees the change, sets the session to null, and the app navigates back to a public page.
Nothing about the flows is React-specific. React's job is holding the session state and re-rendering when it changes.
Guarding routes, and who actually enforces access
With session state in context, the auth-required layout route from the protected routes chapter becomes a ternary. All three session values get a branch:
function AuthRequired() {
const { session } = useAuth()
if (session === undefined) return <p>Loading...</p>
return session ? <Outlet /> : <Navigate to="/signin" replace />
}Nest the private pages under it and unauthenticated visitors get redirected before any of them render. That chapter made the point that a client guard is a user experience feature; here is the other half. Real access control has to live on the server, where users can't reach.
The course's example of server-side enforcement is Row Level Security, a database feature where access policies attach to the tables themselves. Enable it and everything is denied by default; then policies grant access based on claims in the request's JWT, such as role = 'authenticated' or a row's owner column matching the token's sub. The policy is enforced inside the database on every query, so it holds even for a request that skips your React app.
What it allows is exactly what the policy says, and a request carrying nothing but the publishable key is a legitimate request, so a policy written as always-true protects nothing: the rule has to name the claim it checks. The route guard and the policy work as a pair, and the policy is the half carrying the weight.
Behind it all is a signed token that travels with every request to prove who's asking, and anyone holding that token can read what's inside it, so keep secrets out of it.
Route guards politely redirect signed-out visitors, but the real locks are on the server.
Next up: TypeScript in React, where props, state, and components pick up types.

