diff --git a/README.md b/README.md
index 58cd533..8c5b719 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ with the rest of the Auth0 ecosystem.
- [Quick start](#quick-start)
- [Protecting routes](#protecting-routes)
- [Calling APIs with access tokens](#calling-apis-with-access-tokens)
+- [SPA mode](#spa-mode)
- [Known limitations](#known-limitations)
- [Security considerations](#security-considerations)
- [Feedback](#feedback)
@@ -124,7 +125,7 @@ the constructor.
| `AUTH0_CLIENT_SECRET` | Application client secret |
| `AUTH0_SESSION_SECRET` | Random string (min 32 chars) used to encrypt the session cookie |
| `AUTH0_APP_BASE_URL` | Full URL of your app, e.g. `https://example.com` (optional — inferred from `request.url` at runtime, but should be set explicitly in production when running behind a reverse proxy) |
-| `AUTH0_AUDIENCE` | API audience, if requesting access tokens for an API (optional) |
+| `AUTH0_AUDIENCE` | API audience for access tokens (optional). Without this, `getAccessToken` returns an opaque token that API servers cannot validate. Set it to the identifier of your registered API (e.g. `https://api.example.com`) to receive a signed RS256 JWT instead. |
| `AUTH0_SCOPE` | OAuth scopes, defaults to `openid profile email` (optional) |
**2. Register the auth routes:**
@@ -325,6 +326,138 @@ Silent refresh only works when **all three** of the following are configured. If
3. **Refresh Token grant is enabled on the application** — Auth0 Dashboard → Applications →
select your app → Settings → Advanced Settings → Grant Types → check **Refresh Token**.
+## SPA mode
+
+SPA mode is activated automatically when `VITE_AUTH0_DOMAIN` and `VITE_AUTH0_CLIENT_ID` are both
+set in your Vite environment. No `Auth0Server`, no loaders, no server session — the full OIDC flow
+runs in the browser via `@auth0/auth0-spa-js`.
+
+The same `Auth0Provider`, hooks, and components work in both modes. The provider detects the
+`VITE_*` variables at render time and switches to the SPA-backed implementation internally.
+
+**Environment variables:**
+
+| Variable | Description |
+| --------------------------------------- | ----------------------------------------------------------------------------- |
+| `VITE_AUTH0_DOMAIN` | Your Auth0 tenant domain — activates SPA mode when set alongside client ID |
+| `VITE_AUTH0_CLIENT_ID` | Application client ID |
+| `VITE_AUTH0_REDIRECT_URI` | Callback URL (optional — defaults to `window.location.origin`) |
+| `VITE_AUTH0_AUDIENCE` | API audience for RS256 access tokens (optional — see note below) |
+| `VITE_AUTH0_SCOPE` | OAuth scopes (optional — defaults to `openid profile email`) |
+| `VITE_AUTH0_CACHE_LOCATION` | Token cache: `memory` (default) or `localstorage` |
+| `VITE_AUTH0_USE_REFRESH_TOKENS` | Enable refresh tokens (optional — defaults to `true`) |
+| `VITE_AUTH0_USE_REFRESH_TOKENS_FALLBACK`| Fall back to silent iframe SSO if refresh token is absent (optional) |
+
+> **API tokens** — without `VITE_AUTH0_AUDIENCE`, `getAccessToken()` returns an opaque token that
+> cannot be validated by an API server. Set `VITE_AUTH0_AUDIENCE` to the identifier of your
+> registered API and it returns a signed RS256 JWT instead.
+
+### Handling the loading state
+
+In SPA mode the SDK initialises asynchronously — `isLoading` is `true` until `auth0-spa-js`
+finishes restoring the session. During this window `SignedIn`, `SignedOut`, and `RequireRole` all
+render nothing to avoid a flash of incorrect UI.
+
+Use `AuthLoading` to show a spinner or placeholder while auth state is being resolved:
+
+```tsx
+import { AuthLoading, SignedIn, SignedOut, LoginButton, LogoutButton } from '@auth0/auth0-react-router';
+
+function Header() {
+ return (
+
+ );
+}
+```
+
+Without `AuthLoading`, the nav will be empty during init and then snap to the correct state once
+loading completes. On fast connections the gap is imperceptible; on slow connections or cold loads
+it is visible.
+
+### Persistent sessions
+
+By default tokens are stored in **memory** and lost on page refresh — the user has to log in again.
+This is the secure default: in-memory tokens are not accessible to XSS attacks.
+
+To persist sessions across page refreshes, three steps are required:
+
+**1. Enable Offline Access on your Auth0 API**
+
+Auth0 Dashboard → Applications → APIs → [your API] → Settings → **Allow Offline Access** → ON
+
+**2. Enable the Refresh Token grant on your application**
+
+Auth0 Dashboard → Applications → [your app] → Settings → Advanced Settings → Grant Types →
+**Refresh Token** → checked
+
+**3. Set these variables in your `.env`**
+
+```sh
+VITE_AUTH0_CACHE_LOCATION=localstorage
+VITE_AUTH0_SCOPE=openid profile email offline_access
+```
+
+With this in place, `auth0-spa-js` stores the refresh token in `localStorage` and silently
+exchanges it for a new access token on every page load — no login prompt needed.
+
+> **Security note:** `localstorage` tokens are readable by any JavaScript on the page. Only use
+> this if your application has strong XSS mitigations in place. When in doubt, keep the default
+> `memory` cache and accept that sessions do not survive a hard refresh.
+
+### `useUser()` returns profile fields only in SPA mode
+
+`useUser()` works in both modes but the shape of the user object differs because the two providers
+populate it from different sources.
+
+In **RWA mode** the user comes from the server-side session, which stores the full decoded ID token.
+In **SPA mode** it comes from `auth0-spa-js`'s `getUser()`, which returns UserInfo profile claims
+only and strips JWT metadata automatically.
+
+| Claim | RWA | SPA |
+| ----- | --- | --- |
+| `sub`, `email`, `name`, `picture` | ✅ | ✅ |
+| Custom claims (e.g. roles) | ✅ | ✅ |
+| `iss`, `aud`, `iat`, `exp`, `sid` | ✅ | ❌ |
+
+If your code reads JWT metadata claims directly from `useUser()`, those fields will be `undefined`
+in SPA mode. Use `useAuth0().getIdTokenClaims()` if you need the full ID token payload.
+
+### Server helpers are not available in SPA mode
+
+`getSession`, `requireSession`, `getUser`, `requireUser`, and `getAccessToken` are server-side
+utilities that read the encrypted JWE session cookie. In a pure SPA there is no session cookie —
+those helpers will always return `null` or redirect to login. Use the client-side hooks instead:
+
+| Instead of (server) | Use (client) |
+| ------------------- | ------------ |
+| `getSession(request)` | `useSession()` |
+| `getUser(request)` | `useUser()` |
+| `getAccessToken(request)` | `useAuth0().getAccessToken()` |
+| `requireSession(request)` | `RequireAuth` component |
+
+### Auth0 Dashboard configuration notes
+
+**Logout `returnTo`** — the URL passed to `logout({ returnTo: '...' })` must be registered in the
+Auth0 Dashboard under Applications → [your app] → Settings → **Allowed Logout URLs**. Auth0
+validates the URL before redirecting — an unregistered URL results in an error page after logout.
+
+**Sign-up screen** — passing `screen_hint: "signup"` to `loginWithRedirect` shows the sign-up
+form instead of the login form, but requires two things:
+
+1. Your tenant must use **New Universal Login** (Auth0 Dashboard → Branding → Universal Login).
+2. Sign-ups must be enabled on the application (Auth0 Dashboard → Applications → [your app] →
+ Settings → scroll to "Application Login URI" section).
+
## Known limitations
- **React Router middleware** — `auth0Middleware`, `bearerTokenMiddleware`, and `defineRouteAuth`
@@ -337,6 +470,11 @@ Silent refresh only works when **all three** of the following are configured. If
(Dashboard → Applications → APIs → Create API). After creating the API, also authorize your
application on the API's **Machine to Machine Applications** tab, otherwise login fails with
"Client is not authorized to access resource server".
+- **Hybrid mode (both `AUTH0_*` and `VITE_*` set) is not supported** — when both sets of
+ environment variables are present, `Auth0Provider` activates SPA mode but the server-side JWE
+ session cookie is also present. SPA logout clears the in-browser token cache but does not clear
+ the cookie, so routes guarded by `requireSession` continue to serve content after logout. Run the
+ app in one mode only: set either `AUTH0_*` (SSR) or `VITE_*` (SPA) variables, not both.
## Security considerations
diff --git a/__tests__/client/components.test.tsx b/__tests__/client/components.test.tsx
index 0a92c0b..e59d4e6 100644
--- a/__tests__/client/components.test.tsx
+++ b/__tests__/client/components.test.tsx
@@ -192,6 +192,18 @@ describe('RequireRole', () => {
);
expect(screen.getByText('Custom claim')).toBeDefined();
});
+
+ it('renders nothing while isLoading — does not throw during SPA init', () => {
+ const ctx = createMockAuth0Context({ isLoading: true, isAuthenticated: false, session: null });
+ const { container } = render(
+
+
+ Admin
+
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
});
// ─── SignedIn / SignedOut / AuthLoading ───────────────────────────────────────
diff --git a/src/client/components.tsx b/src/client/components.tsx
index 82705bd..61c0883 100644
--- a/src/client/components.tsx
+++ b/src/client/components.tsx
@@ -66,7 +66,19 @@ export function RequireRole({
rolesClaim = DEFAULT_ROLES_CLAIM,
children
}: RequireRoleProps) {
- const { user, isAuthenticated } = useAuth0();
+ const { user, isAuthenticated, isLoading } = useAuth0();
+
+ // Error boundaries are a client-only React mechanism. Throwing during SSR
+ // bypasses Auth0ErrorBoundary and surfaces at the framework root error handler.
+ // Return null on the server and defer the role check to the client after hydration.
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ // In SPA mode, isLoading is true during SDK initialisation. Throwing here
+ // would cause Auth0ErrorBoundary to render the error fallback for every user
+ // before auth state is known — including users who do have the required role.
+ if (isLoading) return null;
// Error boundaries are a client-only React mechanism. Throwing during SSR
// bypasses Auth0ErrorBoundary and surfaces at the framework root error handler.
@@ -106,8 +118,8 @@ export function SignedIn({ children }: { children: ReactNode }) {
* Renders children only when the user is not authenticated.
*/
export function SignedOut({ children }: { children: ReactNode }) {
- const { isAuthenticated } = useAuth0();
- return !isAuthenticated ? <>{children}> : null;
+ const { isAuthenticated, isLoading } = useAuth0();
+ return !isLoading && !isAuthenticated ? <>{children}> : null;
}
/**