react-resource-view
Authentication with JWT
API Platform and LexikJWT with react-jwt-session: a refresh token, protected scopes, roles and sign-out.
The recipe at a glance#
Most API Platform applications sign their users in with LexikJWTAuthenticationBundle: the API hands out a JWT, the browser sends it back on every request. This page wires that session to the scopes of react-resource-view with react-jwt-session 0.2 — the same steps as the Better Auth recipe, and the same scope contract underneath: an authorization that ends in true, a 401 or a 403.
- Symfony signs in on
/api/auth, renews on/api/auth/refreshand revokes on/api/auth/logout. - react-jwt-session stores the token and the refresh token, and renews the first before it expires.
- Each scope awaits the session through
createScopeAuthorization: no session sends the reader to sign in, a missing role shows a refusal. - Flags and menu entries read the roles synchronously, from the JWT the scope has just checked.
Each block below is meant to be copied as it is.
Symfony: what the API provides#
Installing the bundle and generating its keys is Lexik's own documentation. Only three things matter to the front end.
A sign-in route answering a token
A short-lived JWT — an hour — since nothing revokes it before it expires; the refresh token is what keeps the reader signed in.
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
# A JWT cannot be revoked: keep it short-lived, the refresh token keeps the session.
token_ttl: 3600api_auth:
path: /api/auth
methods: [POST]security:
firewalls:
main:
pattern: ^/api
stateless: true
provider: users
jwt: ~
# POST /api/auth { email, password } → { token }
json_login:
check_path: api_auth
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
# 5 failures per minute for one IP + email (needs symfony/rate-limiter).
login_throttling:
max_attempts: 5
interval: '1 minute'
access_control:
# Signing in, renewing and signing out happen without a valid JWT.
- { path: ^/api/auth, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }Lexik puts the user's getRoles() in the token payload, under roles: that array is what every role check on this page reads.
role_hierarchy stays on the server
The token carries the roles stored on the user, not the ones role_hierarchy derives from them. With ROLE_ADMIN: ROLE_EDITOR, an administrator's JWT holds ROLE_ADMIN only — so list every role that should get in, as the examples below do.
A refresh token next to it
Lexik has no refresh token of its own: add one with gesdinet/jwt-refresh-token-bundle — its token_parameter_name set to refreshToken — or with two endpoints of yours. Either way, react-jwt-session expects this contract:
POST /api/authanswers{ token, refreshToken }— the JWT and, next to it, the refresh token.POST /api/auth/refreshtakes{ refreshToken }and answers a new{ token, refreshToken }; both are stored again. A 4xx means the refresh token is dead — expired or revoked — and ends the session; a 5xx or a network failure keeps it, and the renewal is tried again later.POST /api/auth/logouttakes{ refreshToken }and revokes it.
A distinct answer to throttling
login_throttling refuses a sign-in with an authentication exception, which Lexik's failure handler turns into a 401 — the answer to a wrong password. To tell the reader to wait rather than to check their input, listen to Lexik's AUTHENTICATION_FAILURE event and, when the exception is a TooManyLoginAttemptsAuthenticationException, replace the response with a 429 carrying a message. The authenticator below turns that 429 into a TooManyLoginAttemptsError.
Configuring react-jwt-session#
pnpm add react-jwt-session ssr-safe-storagesetUserConfig is where the package learns your API: how to sign in, where the user comes from, and the two URLs of the refresh token. The same file hands the JWT to the client the views request through.
import { configureClient } from "jsonld-api-client"
import {
getUserToken,
isLogged,
keepSessionAlive,
setUserConfig,
setUserToken,
TooManyLoginAttemptsError,
type LoginReponseInterface,
type UserInterface,
} from "react-jwt-session"
const API_URL = "https://api.example.com"
setUserConfig({
// POST /api/auth { email, password } → { token, refreshToken }
authenticator: async (credentials) => {
const response = await fetch(`${API_URL}/api/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
})
// login_throttling: the message says how long to wait.
if (response.status === 429) {
throw new TooManyLoginAttemptsError((await response.json()).message)
}
if (!response.ok) throw new Error("Invalid credentials.")
const tokens: LoginReponseInterface = await response.json()
setUserToken(tokens)
return tokens
},
// The signed-in user, fetched once the token is stored.
getUser: async () => {
const response = await fetch(`${API_URL}/api/me`, {
headers: { Authorization: `Bearer ${getUserToken()}` },
})
return response.ok ? ((await response.json()) as UserInterface) : undefined
},
// A new session: start renewing it.
onLoginSuccess: async () => {
void keepSessionAlive()
},
// POST { refreshToken } → { token, refreshToken }
refreshUrl: `${API_URL}/api/auth/refresh`,
// POST { refreshToken }, on logout()
logoutUrl: `${API_URL}/api/auth/logout`,
})
// Every request of the views carries the JWT.
configureClient({
baseUrl: API_URL,
getAuthToken: () => (isLogged() ? getUserToken() : undefined),
})| Name | Type | Default | Description |
|---|---|---|---|
authenticator | (credentials) => Promise<LoginReponseInterface> | — | Exchanges the credentials for tokens. Store them with setUserToken — the refresh token goes under its own key. |
getUser | () => Promise<UserInterface | undefined> | — | The signed-in user — an API Platform operation returning the current user. Roles do not come from here but from the JWT. |
onLoginSuccess | ({ user }) => Promise<void> | — | Runs after a successful sign-in. |
refreshUrl | string | — | Posted { refreshToken } to renew the JWT. Without it, the session lasts as long as the JWT. |
logoutUrl | string | — | Posted { refreshToken } by logout(), to revoke it. |
Your own HTTP client
refreshTokenRequest and revokeRefreshToken replace the two URLs with functions of yours, and take precedence over them — see the README.
Keeping the session alive#
Call keepSessionAlive() once, when the application starts. It renews the JWT two minutes before it expires, schedules the next renewal, and checks again when the tab becomes visible or the network comes back — a laptop put to sleep lets the hour pass without its timer noticing. Without a refresh token it does nothing, so it is safe to call before anyone has signed in.
import "./session" // configures the session before anything reads it
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { keepSessionAlive, UserProvider } from "react-jwt-session"
import { App } from "./App"
// Renews the JWT before it expires, and again when the tab comes back.
void keepSessionAlive()
createRoot(document.getElementById("root")!).render(
<StrictMode>
<UserProvider>
<App />
</UserProvider>
</StrictMode>
)After a sign-in, a new refresh token exists: the onLoginSuccess of session.ts calls keepSessionAlive() again to start renewing it. UserProvider is what the login form below reads useUserContext() from.
Protecting a scope#
react-jwt-session/resource-view turns the session into the async scope authorization react-resource-view expects — one line per scope:
import { createScopeAuthorization } from "react-jwt-session/resource-view"
import type { ScopeInterface } from "react-resource-view"
import { AdminLayout } from "react-resource-view"
import { articles } from "../resources/articles"
import { users } from "../resources/users"
export const adminScope: ScopeInterface = {
name: "admin",
label: "Administration",
resources: [articles, users],
decoratorComponent: AdminLayout,
// No session → 401 → onUnauthorized. None of these roles → 403.
authorization: createScopeAuthorization({ roles: ["ROLE_ADMIN", "ROLE_EDITOR"] }),
}- Nobody signed in, or a session that could not be renewed — throws
UnauthorizedError, a 401. - A session holding none of the
roles— throwsForbiddenError, a 403: signed in, but not allowed here. - Otherwise —
true, and the scope renders. Leaverolesout to let any signed-in user in.
Before answering, it awaits loadSession(), which renews the JWT first when it has expired or is about to. A reader coming back the next day, with an expired JWT but a live refresh token, lands on the page they asked for — not on the login form.
An optional peer dependency
The sub-path imports UnauthorizedError and ForbiddenError from react-resource-view, an optional peer dependency of react-jwt-session: only this entry needs it.
Redirecting to sign in#
onUnauthorized on the configuration receives every 401 of every scope. Pass the page the reader was on, so the login form can bring them back to it.
import { useRouter } from "@tanstack/react-router"
import { parseLink, ResourceViewProvider } from "react-resource-view"
export function Admin({ url }: { url: string }) {
const router = useRouter()
return (
<ResourceViewProvider
viewResourceContextParams={parseLink(url)}
configuration={{
scopes: {
admin: () => import("./scopes/admin").then((m) => m.adminScope),
},
defaultScope: "admin",
// No session, or one that could not be renewed: sign in, then come back.
onUnauthorized: () =>
router.navigate({
to: "/login",
search: { redirect: window.location.pathname },
}),
// While loadSession() resolves — a renewal may be under way.
authorizationFallback: <p>Checking your session…</p>,
// Signed in, but without any of the scope's roles.
forbiddenFallback: <p>This area is for administrators.</p>,
}}
/>
)
}| Name | Type | Default | Description |
|---|---|---|---|
onUnauthorized | () => void | — | Called on a 401, thrown or rejected. |
authorizationFallback | ReactNode | — | Shown while authorization is pending — here, while the session is read and perhaps renewed. Defaults to the page loader. Also accepted on the scope, which wins. |
forbiddenFallback | ReactNode | — | Shown on a 403 or a false. Defaults to an “Access denied” message. Also accepted on the scope, which wins. |
The login form signs in through useUserContext(), then goes back to redirect. A locked account gets the API's message; anything else, the usual one.
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { type FormEvent, useState } from "react"
import { TooManyLoginAttemptsError, useUserContext } from "react-jwt-session"
export const Route = createFileRoute("/login")({
// ?redirect=/admin/articles — set by onUnauthorized.
validateSearch: (search: Record<string, unknown>): { redirect?: string } => ({
redirect: typeof search.redirect === "string" ? search.redirect : undefined,
}),
component: LoginPage,
})
function LoginPage() {
const { redirect } = Route.useSearch()
const router = useRouter()
const { authenticator } = useUserContext()
const [error, setError] = useState<string>()
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = new FormData(event.currentTarget)
try {
// Stores the tokens, loads the user, then runs onLoginSuccess.
await authenticator({
email: String(form.get("email")),
password: String(form.get("password")),
})
} catch (e) {
return setError(
e instanceof TooManyLoginAttemptsError ? e.message : "Wrong email or password."
)
}
router.navigate({ to: redirect ?? "/admin" })
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" autoComplete="username" required />
<input name="password" type="password" autoComplete="current-password" required />
{error && <p role="alert">{error}</p>}
<button type="submit">Sign in</button>
</form>
)
}Roles, permissions and menu#
The scope decides who gets in; the permissions decide what each reader is offered inside. They are asked per action and per row, so they should not wait for a request — and they do not need to: hasRole(role) reads the roles of the stored JWT synchronously, and returns false once it has expired.
The scope's authorization has awaited loadSession() before any view of the scope renders — so by then the JWT has been renewed if it had to be, and reading it synchronously is safe. loadSession() itself is there for your own async code:
import { loadSession } from "react-jwt-session"
const session = await loadSession()
// { user, roles, token } — or null when nobody is signed in
session?.roles.includes("ROLE_ADMIN")The resources read the roles:
import { hasRole } from "react-jwt-session"
import { createViewResource } from "react-resource-view"
export const articles = createViewResource("articles", {
path: "/api/articles",
// Everything is allowed by default: only the restrictions are written.
permissions: {
create: () => hasRole("ROLE_EDITOR") || hasRole("ROLE_ADMIN"),
update: () => hasRole("ROLE_EDITOR") || hasRole("ROLE_ADMIN"),
delete: () => hasRole("ROLE_ADMIN"),
},
})And so does the menu — the same scope file, with a getter on the entry:
import { hasRole } from "react-jwt-session"
import { createScopeAuthorization } from "react-jwt-session/resource-view"
import type { ScopeInterface } from "react-resource-view"
import { AdminLayout, createItemMenuWithResource } from "react-resource-view"
import { articles } from "../resources/articles"
import { users } from "../resources/users"
export const adminScope: ScopeInterface = {
name: "admin",
label: "Administration",
resources: [articles, users],
decoratorComponent: AdminLayout,
authorization: createScopeAuthorization({ roles: ["ROLE_ADMIN", "ROLE_EDITOR"] }),
menu: [
articles,
{
// An entry of its own, to add a getter: it names the scope its link leads to.
...createItemMenuWithResource({ resource: users, scope: "admin" }),
// `hidden` is read each time the menu renders: a getter follows the session.
get hidden() {
return !hasRole("ROLE_ADMIN")
},
},
],
}Inside React, useUserContext works too
A component of yours — a top bar, an avatar — can read useUserContext() for the user and its hasRole. The plain hasRole exists for the places that are not components: permissions and menu entries, declared once, outside any render.
Signing out#
Signing out changes nothing the scope can see by itself. End the session, then ask the scope to check again: the authorization now finds no session, throws a 401, and onUnauthorized takes the reader to /login — no reload.
import { logout } from "react-jwt-session"
import { useScopeContext } from "react-resource-view"
export function SignOutButton() {
const scopeContext = useScopeContext()
return (
<button
onClick={async () => {
// Clears the tokens and the profile, then revokes the refresh token.
await logout()
// Runs the scope's authorization again: no session → 401 → /login.
scopeContext?.recheckAuthorization()
}}
>
Sign out
</button>
)
}logout()empties the storage at once — JWT, refresh token, profile — and forgets the sessionloadSession()kept. It then posts the refresh token tologoutUrl; awaiting it lets the revocation leave before anything else happens. It never rejects.recheckAuthorization()hides the scope behind its fallback and runsauthorizationagain.
The button has to render inside the scope to reach its context — AdminLayout's sidebarFooter is a natural place for it.
The API decides#
This only protects the interface
Everything on this page runs in the browser, where anyone can change it, and react-jwt-session never verifies the token's signature. It decides what is shown. API Platform must check the JWT and the role again on every request — access_control, a security expression on the operation — or the protected data is one fetch away.