Skip to content

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:

jsx
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:

js
// 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:

jsx
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:

jsx
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:

jsx
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.

The stateless design is a trade with two sides. The server skips a session lookup on every request, which is the win. The cost is revocation: since no list of live sessions exists, a stolen or signed-out token remains cryptographically valid until it expires.

Providers manage that with short-lived access tokens paired with long-lived refresh tokens. The client library hides the churn: getSession checks the stored token's expiry, and when it finds an expired token it silently exchanges the refresh token for a fresh access token, surfacing null only when that exchange fails. Instant revocation requires reintroducing server-side state, a denylist checked per request, which is why short expiries are the default answer.

The two token hazards from earlier have a sharper frame. A signature guarantees integrity while leaving confidentiality untouched: a verifier learns the token is unaltered and learns nothing about who else has read it along the way.

On storage, client-side SDKs commonly default to local storage and lean on the short access-token expiry to bound the damage, which is a bet that a stolen token goes stale before it is worth much. Moving to httpOnly cookies relocates the problem rather than removing it, since CSRF mitigation then becomes yours to get right.

The enforcement rule generalizes past databases. Row Level Security is one implementation; the principle is that every trust decision must happen where the attacker has no write access. Whatever the client sends, including the JWT itself, is attacker-controlled input until the server verifies the signature. That verification is the single moment untrusted input becomes trusted identity, which is why the signing key is the crown jewel: whoever holds it can mint valid tokens for any user.

JunoThe session is state, the server is the law Signing in gives your app a session, and that session is React state kept in a context provider so every component can ask "is someone signed in?" It has three answers: still checking, signed out, and signed in, and the "still checking" one keeps users from being bounced to the login page while the app looks for an existing session.

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.

JunoThe session is state, the server is the law Build an AuthProvider with a useAuth hook, exactly like a theme provider. Initialize the session as undefined, then in one effect call getSession for the refresh case and subscribe to auth changes for everything after.

Sign in, sign up, and sign out all follow one shape: call the SDK, let the listener update state, navigate on success.

Wire the session into a protected route with a loading branch, and put the actual access rules server-side, with policies like Row Level Security that check the JWT on every query.

JunoThe session is state, the server is the law JWT auth trades revocation for statelessness: the signed token is the session, so nothing expires it early without a server-side denylist, and short-lived access tokens plus refresh rotation are the standard mitigation. The payload is readable by anyone holding the token, so the signature guarantees integrity while secrets stay out of claims, and where you store the token decides whether XSS or CSRF is your problem.

Treat everything the client sends as untrusted until signature verification, and enforce authorization where the attacker cannot write: the server.

Next up: TypeScript in React, where props, state, and components pick up types.