- Custom login parameters
- Sign-up screen
- Wiring hooks to standalone helpers
- Protecting a route (server-side)
- Protecting a route (client-side)
- Role-based route protection
- Handling auth errors
- Accessing session data in loaders
- Calling an API with an access token
- Handling token expiry
- Updating and deleting session data
- Organizations
- SPA mode
- Protecting API routes with Bearer tokens
- Back-channel logout
- Testing authenticated flows
- Key behaviors
Pass extra authorization parameters to handleLogin or link to /auth/login directly:
// Using the LoginButton component with extra params
import { LoginButton } from '@auth0/auth0-react-router';
<LoginButton authorizationParams={{ connection: 'google-oauth2' }}>
Sign in with Google
</LoginButton>Or trigger login from a loader/action and forward parameters via the query string:
/auth/login?connection=google-oauth2&returnTo=/dashboard
handleLogin reads connection, audience, scope, screen_hint, and any other
authorizationParams from the search params automatically.
Show the Auth0 sign-up form instead of the login form by passing screen_hint=signup.
This requires New Universal Login to be enabled on your tenant (Auth0 Dashboard →
Branding → Universal Login).
import { LoginButton } from '@auth0/auth0-react-router';
<LoginButton authorizationParams={{ screen_hint: 'signup' }}>
Create account
</LoginButton>Or link directly:
/auth/login?screen_hint=signup
By default, standalone helpers (getSession, updateSession, deleteSession,
getAccessToken, etc.) use an internal Auth0Server instance that has no hooks.
Call registerAuth0Instance once after constructing your instance so that
onCallback and beforeSessionSaved fire across all helpers, not just handleCallback:
// app/auth0.server.ts
import { Auth0Server, registerAuth0Instance } from '@auth0/auth0-react-router/server';
export const auth0 = new Auth0Server({
async beforeSessionSaved(session) {
// Runs before the session cookie is written — add custom claims here
return { ...session, user: { ...session.user, app_role: 'member' } };
},
onCallback(session) {
// Runs after a successful login — provision the user in your database here
},
});
registerAuth0Instance(auth0);Note: If you skip
registerAuth0Instance,beforeSessionSavedandonCallbackwill only fire during the login callback. They will not fire when the session is updated viaupdateSessionor refreshed viagetAccessToken.
Use requireSession in a loader to block unauthenticated requests at the server.
It returns the session or throws a 302 redirect to /auth/login with a returnTo
parameter so the user lands back on the original page after login:
// app/routes/dashboard.tsx
import { requireSession } from '@auth0/auth0-react-router/server';
export const loader = async ({ request }) => {
const session = await requireSession(request);
return { user: session.user };
};Use requireUser when you only need the user profile, not the full session:
import { requireUser } from '@auth0/auth0-react-router/server';
export const loader = async ({ request }) => {
const user = await requireUser(request);
return { user };
};Note: Client-side guards control rendering after hydration — they do not block the server response. For real server-side protection use
requireSessionin the loader.
Redirect to login when unauthenticated:
import { RequireAuth } from '@auth0/auth0-react-router';
export default function Dashboard() {
return (
<RequireAuth>
<DashboardContent />
</RequireAuth>
);
}Show/hide UI based on auth state:
import { SignedIn, SignedOut, AuthLoading, LoginButton, LogoutButton } from '@auth0/auth0-react-router';
function Header() {
return (
<nav>
<AuthLoading>
<Spinner />
</AuthLoading>
<SignedIn>
<LogoutButton />
</SignedIn>
<SignedOut>
<LoginButton />
</SignedOut>
</nav>
);
}Higher-order component:
import { withAuthenticationRequired } from '@auth0/auth0-react-router';
const PrivatePage = () => <div>Private content</div>;
export default withAuthenticationRequired(PrivatePage, {
onRedirecting: () => <div>Redirecting to login…</div>,
});Use defineRouteAuth middleware to enforce roles at the route level. The middleware
reads roles from the https://auth0.com/claims/roles claim by default.
// app/routes/admin.tsx
import { defineRouteHandle } from '@auth0/auth0-react-router';
import { defineRouteAuth, auth0UserContext } from '@auth0/auth0-react-router/server';
// handle is read client-side (e.g. by useMatches for breadcrumbs)
export const handle = defineRouteHandle({ role: 'admin' });
// middleware is stripped from the client bundle by React Router
export const middleware = defineRouteAuth({ role: 'admin' }).middleware;
export const loader = ({ context }) => {
// User is guaranteed to exist and have the role here
const user = context.get(auth0UserContext);
return { user };
};Requests without the required role receive a 403. To use a non-default roles claim:
export const middleware = defineRouteAuth({
role: 'admin',
rolesClaim: 'https://myapp.example.com/roles',
}).middleware;Client-side role guard:
import { RequireRole } from '@auth0/auth0-react-router';
<RequireRole role="admin">
<AdminPanel />
</RequireRole>Wrap any component that might throw an Auth0 error in Auth0ErrorBoundary. All eight
error classes are caught, useful around RequireRole and other guards that throw on failure:
import { Auth0ErrorBoundary, RequireRole } from '@auth0/auth0-react-router';
export default function AdminPanel() {
return (
<Auth0ErrorBoundary
fallback={(error) => (
<div>
<p>Access denied: {error.message}</p>
<p>Code: {error.code} — Status: {error.statusCode}</p>
</div>
)}
>
<RequireRole role="admin">
<AdminContent />
</RequireRole>
</Auth0ErrorBoundary>
);
}Error classes exported from @auth0/auth0-react-router/errors:
| Class | code |
statusCode |
|---|---|---|
AuthenticationError |
authentication_error |
401 |
SessionExpiredError |
session_expired |
401 |
MissingSessionError |
missing_session |
401 |
TokenError |
token_error |
401 |
BearerTokenError |
bearer_token_error |
401 |
CallbackError |
callback_error |
400 |
InsufficientScopeError |
insufficient_scope |
403 |
ConfigurationError |
configuration_error |
500 |
Read the session or user without throwing a redirect — useful when auth is optional:
import { getSession, getUser } from '@auth0/auth0-react-router/server';
export const loader = async ({ request }) => {
const session = await getSession(request); // Auth0Session | null
const user = await getUser(request); // Auth0User | null
return { user };
};Read from React Router middleware context when using auth0Middleware:
import { auth0SessionContext, auth0UserContext } from '@auth0/auth0-react-router/server';
export const loader = ({ context }) => {
const session = context.get(auth0SessionContext); // Auth0Session | null
const user = context.get(auth0UserContext); // Auth0User | null
return { user };
};Use getAccessToken in a loader or action. Expired tokens are silently refreshed
before being returned.
// app/routes/orders.tsx
import { getAccessToken } from '@auth0/auth0-react-router/server';
export const loader = async ({ request }) => {
const token = await getAccessToken(request);
const data = await fetch('https://api.example.com/orders', {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());
return { data };
};Or use createApiClient to get a pre-configured client that injects the token
automatically:
import { createApiClient } from '@auth0/auth0-react-router/server';
export const loader = async ({ request }) => {
const api = createApiClient(request, { baseUrl: 'https://api.example.com' });
const orders = await api('/orders').then(r => r.json());
return { orders };
};When AUTH0_SCOPE does not include offline_access or the refresh token is missing,
getAccessToken throws a TokenError. Clear the stale session and redirect to login:
import { getAccessToken, deleteSession } from '@auth0/auth0-react-router/server';
import { TokenError } from '@auth0/auth0-react-router/errors';
export const loader = async ({ request }) => {
let token: string;
try {
token = await getAccessToken(request);
} catch (err) {
if (err instanceof TokenError) {
return deleteSession(request, { redirectTo: '/auth/login' });
}
throw err;
}
const data = await fetch('https://api.example.com/items', {
headers: { Authorization: `Bearer ${token}` },
}).then(r => r.json());
return { data };
};Token refresh prerequisites — silent refresh requires all three of:
AUTH0_SCOPE=openid profile email offline_accessin your.env- Allow Offline Access enabled on your API (Auth0 Dashboard → Applications → APIs → your API → Settings)
- Refresh Token grant enabled on your application (Auth0 Dashboard → Applications → your app → Settings → Advanced Settings → Grant Types)
Store arbitrary data alongside the Auth0 session:
import { getSession, updateSession } from '@auth0/auth0-react-router/server';
export const action = async ({ request }) => {
const session = await getSession(request);
const response = await updateSession(request, {
...session,
user: { ...session.user, preferences: { theme: 'dark' } },
});
return response;
};Clear the session and redirect (e.g. after a security event):
import { deleteSession } from '@auth0/auth0-react-router/server';
export const action = async ({ request }) => {
return deleteSession(request, { redirectTo: '/' });
};To log users in to a specific Auth0 organization, pass the organization parameter:
/auth/login?organization=org_abc123
Or set it in the Auth0Server constructor so it applies to every login:
// app/auth0.server.ts
import { Auth0Server, registerAuth0Instance } from '@auth0/auth0-react-router/server';
export const auth0 = new Auth0Server({
authorizationParams: {
organization: 'org_abc123',
},
});
registerAuth0Instance(auth0);To accept an organization invitation, pass both organization and invitation:
/auth/login?organization=org_abc123&invitation=inv_xyz
SPA mode activates automatically when VITE_AUTH0_DOMAIN and VITE_AUTH0_CLIENT_ID
are both set. No Auth0Server, no session cookie, no server-side handlers — the full
OIDC flow runs in the browser.
The same Auth0Provider, hooks, and components work in both modes.
# .env
VITE_AUTH0_DOMAIN=example.us.auth0.com
VITE_AUTH0_CLIENT_ID=your_client_idPersistent sessions across page refreshes:
By default tokens are kept in memory and lost on refresh. To persist sessions, three steps are required:
- Enable Allow Offline Access on your Auth0 API
- Enable the Refresh Token grant on your application
- Add to your
.env:
VITE_AUTH0_CACHE_LOCATION=localstorage
VITE_AUTH0_SCOPE=openid profile email offline_accessSecurity note:
localstoragetokens are readable by any JavaScript on the page. Only use this if your application has strong XSS mitigations in place.
Getting an access token in SPA mode:
import { useAuth0 } from '@auth0/auth0-react-router';
function CallApi() {
const { getAccessToken } = useAuth0();
const fetchData = async () => {
const token = await getAccessToken();
await fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${token}` },
});
};
return <button onClick={fetchData}>Fetch data</button>;
}React Router loaders and actions can also serve as API endpoints called by mobile apps, SPAs, or other services using a Bearer JWT.
Single route — requireClaims:
import { requireClaims } from '@auth0/auth0-react-router/server';
export async function loader({ request }) {
const claims = await requireClaims(request, { scope: 'read:orders' });
// no token → 401 { "error": "bearer_token_error", ... }
// missing scope → 403 { "error": "insufficient_scope", ... }
return Response.json(await getOrders(claims.sub));
}Use getClaims when you want to handle the unauthenticated case yourself:
import { getClaims } from '@auth0/auth0-react-router/server';
export async function loader({ request }) {
const claims = await getClaims(request);
if (!claims) return Response.json({ public: true });
return Response.json({ user: claims.sub });
}Shared parent route — bearerTokenMiddleware:
Verify the token once in a parent route and read it from context in child loaders, avoiding repeated JWKS calls:
// app/routes.ts
import { route } from '@react-router/dev/routes';
import { bearerTokenMiddleware } from '@auth0/auth0-react-router/server';
export default [
route('api/*', 'routes/api.$.tsx', {
middleware: [bearerTokenMiddleware],
}),
];
// app/routes/api.users.ts
import { requireClaimsFromContext } from '@auth0/auth0-react-router/server';
export async function loader({ context }) {
const claims = requireClaimsFromContext(context, { scope: 'read:users' });
return Response.json(await getUsers());
}Two environment variables are required — no client secret or session config needed:
AUTH0_DOMAIN=your-tenant.auth0.com
AUTH0_AUDIENCE=https://your-api-identifierAuth0 can terminate a user session from the Dashboard or another application via a
back-channel logout request. The handleAuth splat route handles it automatically on
POST /auth/backchannel-logout.
Requirement: Back-channel logout only works with stateful session storage. You must provide a
sessionStoretoAuth0Server— without it the endpoint always returns 400. See the sessionStore setup section below.
If you are using individual handlers, register it explicitly:
// app/routes/auth.$.tsx
import {
handleLogin,
handleCallback,
handleLogout,
handleBackchannelLogout,
} from '@auth0/auth0-react-router/server';
import { auth0 } from '../auth0.server';
export const action = async ({ request }) => {
const url = new URL(request.url);
if (url.pathname.endsWith('/login')) return handleLogin(auth0, request);
if (url.pathname.endsWith('/callback')) return handleCallback(auth0, request);
if (url.pathname.endsWith('/logout')) return handleLogout(auth0, request);
if (url.pathname.endsWith('/backchannel-logout')) return handleBackchannelLogout(auth0, request);
return new Response('Not found', { status: 404 });
};Register the back-channel logout URL in the Auth0 Dashboard under your application's
Settings → Back-Channel Logout URI:
https://your-app.com/auth/backchannel-logout
By default the SDK stores sessions in encrypted cookies (stateless). Back-channel logout requires stateful storage so the server can look up and delete a session by user ID or session ID when Auth0 sends a logout token.
Implement the SessionStore interface and pass it to Auth0Server:
// app/auth0.server.ts
import { Auth0Server } from '@auth0/auth0-react-router/server';
import type { SessionStore, StateData } from '@auth0/auth0-react-router/server';
// Example in-memory store — replace with Redis, a database, etc. in production.
class MySessionStore implements SessionStore<unknown> {
private store = new Map<string, StateData>();
async get(id: string) {
return this.store.get(id);
}
async set(id: string, data: StateData) {
this.store.set(id, data);
}
async delete(id: string) {
this.store.delete(id);
}
async deleteByLogoutToken(claims: { sub?: string; sid?: string }) {
for (const [id, data] of this.store) {
if (
(claims.sid && data.internal?.sid === claims.sid) ||
(claims.sub && data.user?.sub === claims.sub)
) {
this.store.delete(id);
}
}
}
}
export const auth0 = new Auth0Server({
sessionStore: new MySessionStore(),
});With a sessionStore configured, handleBackchannelLogout returns 204 on success and
the matching session is deleted from your store immediately.
Use the provided test utilities to test loaders, actions, and components without a real Auth0 tenant.
Mocking server-side helpers:
import { createMockLoader, createMockSession, createMockUser } from '@auth0/auth0-react-router/testing';
it('returns user data when authenticated', async () => {
const session = createMockSession({
user: createMockUser({ name: 'Jane Doe' }),
});
const { request } = createMockLoader({ session });
const result = await loader({ request });
expect(result.user.name).toBe('Jane Doe');
});Mocking Bearer token routes:
import { createMockBearerRequest } from '@auth0/auth0-react-router/testing';
it('returns 401 when token is missing', async () => {
const request = createMockBearerRequest(); // no token
const response = await loader({ request });
expect(response.status).toBe(401);
});Wrapping components in a mock provider:
import { render, screen } from '@testing-library/react';
import { WithAuth, createMockAuth0Context } from '@auth0/auth0-react-router/testing';
import { Dashboard } from './Dashboard';
it('shows user name when signed in', () => {
const context = createMockAuth0Context({
isAuthenticated: true,
user: { name: 'Jane Doe' },
});
render(
<WithAuth context={context}>
<Dashboard />
</WithAuth>
);
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});-
Lazy initialization:
Auth0Serverdoes not validate environment variables until the first auth operation. Safe to instantiate at module scope without crashing public routes. -
Register your instance: call
registerAuth0Instance(auth0)after constructingAuth0Serverso that standalone helpers fire youronCallbackandbeforeSessionSavedhooks. -
Context access pattern: use
context.get(auth0SessionContext), notauth0SessionContext.get(context). -
Server-side vs client-side protection:
requireSessionandrequireUserblock the server response entirely.RequireAuth,RequireRole, andwithAuthenticationRequiredare client-only and only control rendering after hydration. -
Tokens stay on the server: tokens are encrypted in the JWE session cookie and never sent to the browser. Use
getAccessTokenin a loader, not a client hook. -
deleteSessionfootgun: returningdeleteSessiondirectly from a protected loader or action causes a redirect loop because the new request hits the same protection check on a blank session. Always pass{ redirectTo }pointing to a public page. -
API routes need
AUTH0_AUDIENCE: without it, tokens are opaque JWE and cannot be validated bygetClaimsorrequireClaims. SetAUTH0_AUDIENCEto your API identifier. -
Hybrid mode is not supported: do not set both
AUTH0_*andVITE_*env vars at the same time. The app runs in one mode only: SSR (AUTH0_*) or SPA (VITE_*).