React Authentication: Add Sign-In to a React App

React authentication comes down to three jobs: sign the user in, keep track of the session across reloads, and make sure the data behind the UI is really protected. The first two happen in your components. The third must happen on the server, because anything in a React bundle can be read and changed by the user. This guide wires all three to a StitchDB project: an auth hook, a protected route, password, magic link and OAuth sign-in, optional MFA, and Row Level Security so the database enforces who sees what.

The examples use a Vite React app, but nothing here depends on Vite. Swap import.meta.env.VITE_* for your framework's environment variables.

#How React authentication works with a hosted backend

When a user signs in, the Auth API returns a session: a short-lived access token (a JWT, valid for one hour by default) and a refresh token. The SDK stores the session in localStorage, refreshes the access token before it expires, and sends it with every database, storage and function call.

On the server side, every request carries that JWT. Postgres reads it, auth.uid() returns the user's id, and your policies decide which rows come back. So your React code does two things: it shows the right screens for the auth state, and it calls the API. It never decides on its own what data a user may access.

ConcernLives inTool
Sign-in forms, buttonsReactstitchdb.auth.* methods
Current user and loading stateReactonAuthStateChange in a context
Hiding pages from signed-out usersReactA protected route component
Who can read which rowsPostgresRow Level Security policies
Session lifetime, refresh, sign-outAuth serverSession settings in Studio

#1. Install and create one client

Terminal
npm install stitchdb-js
JavaScript
// src/stitchdb.js
import { createClient } from 'stitchdb-js'

export const stitchdb = createClient(
  import.meta.env.VITE_STITCHDB_URL, // https://<project-ref>.p.stitchdb.com
  import.meta.env.VITE_STITCHDB_ANON_KEY,
  { auth: { persistSession: true, autoRefreshToken: true } },
)

Use the anon key here. It is meant for browsers. Never put the service_role key in a React app: it bypasses every policy.

#2. An auth context and a hook

Put the session in one place so every component can read it:

JavaScript
// src/AuthProvider.jsx
import { createContext, useContext, useEffect, useState } from 'react'
import { stitchdb } from './stitchdb'

const AuthContext = createContext({ session: null, loading: true })

export function AuthProvider({ children }) {
  const [session, setSession] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    stitchdb.auth.getSession().then(({ data: { session } }) => {
      setSession(session)
      setLoading(false)
    })
    const { data: { subscription } } = stitchdb.auth.onAuthStateChange((event, session) => {
      setSession(session)
    })
    return () => subscription.unsubscribe()
  }, [])

  return <AuthContext.Provider value={{ session, loading }}>{children}</AuthContext.Provider>
}

export const useAuth = () => useContext(AuthContext)

getSession() reads the stored session, so it is fast and works offline. onAuthStateChange then reports every change: INITIAL_SESSION, SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, USER_UPDATED, PASSWORD_RECOVERY and MFA_CHALLENGE_VERIFIED. When you need to be sure the account still exists and is not banned (before a sensitive action, say), call getUser(), which asks the server.

#3. A protected route

With React Router, a wrapper component is enough:

JavaScript
import { Navigate } from 'react-router-dom'
import { useAuth } from './AuthProvider'

export function RequireAuth({ children }) {
  const { session, loading } = useAuth()
  if (loading) return <p>Loading…</p>
  if (!session) return <Navigate to="/sign-in" replace />
  return children
}

Keep in mind what this does and does not do. It stops signed-out users from seeing a page. It does not protect data. Someone can open the dev tools and call your API directly, which is why step 6 matters more than this one.

#4. Sign-in methods

#E-mail and password

JavaScript
const { data, error } = await stitchdb.auth.signUp({
  email: '[email protected]',
  password: 'a-strong-password',
  options: { emailRedirectTo: 'https://example.com/welcome' },
})

await stitchdb.auth.signInWithPassword({ email: '[email protected]', password: 'a-strong-password' })

Confirm e-mail is on for new projects, so signUp returns no session. Show a "check your inbox" screen. The link in the e-mail confirms the account, signs the user in and sends them to emailRedirectTo. That URL must be your Site URL or listed in your redirect URLs; http://localhost:5173/** covers Vite's dev server.

Errors come back on error with a code such as invalid_credentials or email_not_confirmed. Map those to friendly messages instead of showing raw text.

#Password reset

JavaScript
// Password reset: the e-mail link signs the user in and sends them to redirectTo
await stitchdb.auth.resetPasswordForEmail('[email protected]', { redirectTo: 'https://example.com/reset' })
await stitchdb.auth.updateUser({ password: 'a-new-password' }) // on the /reset page

On the /reset page, listen for the PASSWORD_RECOVERY event and show a "new password" form.

JavaScript
await stitchdb.auth.signInWithOtp({
  email: '[email protected]',
  options: { emailRedirectTo: 'https://example.com/welcome', shouldCreateUser: true },
})

// If your e-mail template shows {{ .Token }}, the user can type the code instead
const { data, error } = await stitchdb.auth.verifyOtp({ email: '[email protected]', token: '123456', type: 'email' })

Codes have 6 digits by default and expire after an hour. Codes are handy when users open e-mail on their phone but use your app on a laptop. More options are in Passwordless sign-in and the passwordless login guide.

#OAuth

Create an OAuth app with the provider, set its callback URL to https://<project-ref>.p.stitchdb.com/auth/v1/callback, paste the client ID and secret in Studio, then:

JavaScript
await stitchdb.auth.signInWithOAuth({
  provider: 'github',
  options: { redirectTo: 'https://example.com/welcome', scopes: 'read:user' },
})

The browser goes to GitHub and comes back to redirectTo signed in; the SDK reads the session from the URL because detectSessionInUrl is on by default. Google, GitHub, GitLab, Microsoft, Apple, Discord and a dozen more are supported (OAuth providers).

#Skip the forms entirely

The hosted sign-in page gives you finished screens at https://<ref>.p.stitchdb.com/login, styled with your name, logo and colour, in seven languages. Your sign-in button becomes one call:

JavaScript
import { createClient } from 'stitchdb-js'
const stitchdb = createClient(STITCHDB_URL, STITCHDB_ANON_KEY, { auth: { flowType: 'pkce' } })

// on your sign-in button
await stitchdb.auth.signInWithHostedPage({ redirectTo: 'https://yourapp.com/auth/callback' })

With PKCE the user returns with a one-time code, which the client exchanges for a session on /auth/callback. Tokens never appear in the address bar.

#5. Sign out

JavaScript
await stitchdb.auth.signOut()                     // every session of the user (default)
await stitchdb.auth.signOut({ scope: 'local' })   // this device only
await stitchdb.auth.signOut({ scope: 'others' })  // every other device

Signing out revokes refresh tokens. Access tokens already issued stay valid until they expire, so keep their lifetime short. The default is one hour; you can set it from 5 minutes to 7 days in Authentication → Settings, along with a maximum session length, an inactivity timeout and "single session per user".

#6. Protect the data, not just the page

This is the part that makes your React authentication real. Turn on Row Level Security and write policies with auth.uid():

SQL
alter table public.todos enable row level security;

create policy "Users manage their own todos" on public.todos
  for all to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

Now the React code needs no user filter:

JavaScript
// Returns only the signed-in user's todos
const { data } = await stitchdb.from('todos').select('*')

A signed-out visitor gets an empty list. A user who edits the request in dev tools still only reaches their own rows, because the check happens in Postgres. The same policies apply to Realtime subscriptions and, on storage.objects, to file uploads. If a query returns nothing and you expected rows, open Database → Policies → Check access in Studio: it runs the policies as a chosen user and tells you in plain words which one let them in or kept them out. The Row Level Security guide covers more patterns.

#7. Add a second factor

For admin areas or anything involving money, add TOTP:

JavaScript
const { data: f } = await stitchdb.auth.mfa.enroll({ factorType: 'totp' }) // show f.totp.qr_code
const { data: c } = await stitchdb.auth.mfa.challenge({ factorId: f.id })
await stitchdb.auth.mfa.verify({ factorId: f.id, challengeId: c.id, code: '123456' }) // session is now aal2

Then require it in the database, not only in the UI:

SQL
create policy "MFA required" on public.payouts
  as restrictive for all to authenticated
  using ((auth.jwt() ->> 'aal') = 'aal2');

#Common mistakes

  • Trusting a route guard. Hiding a page is UX. Policies are security.
  • Tables with RLS off. Postgres creates tables with RLS off, and such a table is readable with the anon key. RLS Guard, on by default for new projects, turns RLS on for new tables in public, but older projects may not have it. Studio flags them, and the exposure check shows what an anonymous visitor can read right now.
  • Localhost links in production e-mails. The Site URL defaults to http://localhost:3000. Set it to your real domain before launch.
  • Storing extra profile data in user_metadata and trusting it. Users can change their own user_metadata. Put roles and plans in app_metadata, which only your server can set.

#FAQ

#Where should I store the session in a React app?

The SDK stores it in localStorage by default and refreshes it for you. You can pass your own auth.storage if you need something else.

#Do I need a backend server for React authentication?

No. The browser talks to the Auth API and the REST API directly with the anon key, and Row Level Security authorizes each request. Add an Edge Function only for work that needs a secret.

#How do I let users try the app before signing up?

Turn on anonymous sign-ins and call stitchdb.auth.signInAnonymously(). Later, updateUser({ email }) or linkIdentity({ provider: 'google' }) makes the account permanent and keeps the same user id, so their data stays theirs.

#Can I use existing React auth UI kits?

StitchDB speaks the same Auth API as the open-source Supabase stack, and its compatibility notes say ready-made sign-in UI components for that stack work too. Point them at your project URL and anon key.

Set up auth for your app in a few minutes: create a free project.