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.

  1. Symfony signs in on /api/auth, renews on /api/auth/refresh and revokes on /api/auth/logout.
  2. react-jwt-session stores the token and the refresh token, and renews the first before it expires.
  3. Each scope awaits the session through createScopeAuthorization: no session sends the reader to sign in, a missing role shows a refusal.
  4. 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.

config/packages/lexik_jwt_authentication.yaml
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: 3600
config/routes.yaml
api_auth:
    path: /api/auth
    methods: [POST]
config/packages/security.yaml
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/auth answers { token, refreshToken } — the JWT and, next to it, the refresh token.
  • POST /api/auth/refresh takes { 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/logout takes { 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-storage

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

session.ts
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),
})
NameTypeDefaultDescription
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.
refreshUrlstring—Posted { refreshToken } to renew the JWT. Without it, the session lasts as long as the JWT.
logoutUrlstring—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.

main.tsx
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:

scopes/admin.ts
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 — throws ForbiddenError, a 403: signed in, but not allowed here.
  • Otherwise — true, and the scope renders. Leave roles out 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.

Admin.tsx
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>,
      }}
    />
  )
}
NameTypeDefaultDescription
onUnauthorized() => void—Called on a 401, thrown or rejected.
authorizationFallbackReactNode—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.
forbiddenFallbackReactNode—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.

routes/login.tsx
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:

resources/articles.ts
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:

scopes/admin.ts
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.

SignOutButton.tsx
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>
  )
}
  1. logout() empties the storage at once — JWT, refresh token, profile — and forgets the session loadSession() kept. It then posts the refresh token to logoutUrl; awaiting it lets the revocation leave before anything else happens. It never rejects.
  2. recheckAuthorization() hides the scope behind its fallback and runs authorization again.

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.