Table of Contents
- Core Concepts: AuthN vs AuthZ
- Why the Browser Cannot Keep Secrets
- BFF: Backend for Frontend
- Cookie Authentication
- Same-Site Cookies and CSRF Protection
- Deployment and Reverse Proxy
- OpenID Connect and OAuth2
- Authentication Flows
- Anatomy of a JWT
- Calls to an External API
- BFF Styles
- Authorization in React
- Authorization Data: Approaches
- RBAC with a Centralized Authorization API
- Flow Diagrams
- React Code Snippets
- Reference Tables
- Best Practices and Key Takeaways
1. Core Concepts: AuthN vs AuthZ
Authentication (AuthN)
Authentication is the process of verifying a user’s identity. In real life, a hotel asks for a passport: the document contains claims (name, address, date of birth) and is issued by a trusted authority. In an application, the password plays the role of the passport.
Authentication = Who are you? → Identity verification.
Authorization (AuthZ)
Authorization determines what an authenticated user is allowed to do. At the hotel, the key card grants access to certain areas (your room, the pool) but not all. In an application, access to features is restricted based on the user’s claims.
Authorization = What can you do? → Access control.
Claims
Claims are identity data associated with a user: name, email, birthdate, role, department, employee_id, etc. They come from the Identity Provider during authentication and serve as the basis for authorization.
2. Why the Browser Cannot Keep Secrets
Fundamental rule: Everything sent to the browser is readable and manipulable.
What you should never do
// ❌ BAD PRACTICE - Never do this
const users = [
{ username: "alice", password: "s3cr3t", role: "admin" },
{ username: "bob", password: "p4ss", role: "user" }
];
// ❌ API key exposed in front-end code
const API_KEY = "my-secret-api-key-67890";
// ❌ Token stored in localStorage (accessible via XSS)
localStorage.setItem("access_token", token);
Why it is dangerous
| Attack | Mechanism | Impact |
|---|---|---|
| DevTools (F12) | Reading source code, Network tab, Application tab | Theft of secrets, API keys |
| XSS (Cross-Site Scripting) | Injection of malicious JavaScript | Token theft from localStorage |
| CSRF | Cross-site requests using existing cookies | Unauthorized actions |
| Code substitution | Interception of Authorization Code Flow code | Fraudulent exchange for tokens |
Conclusion: You cannot encrypt securely on the browser side, because the decryption key would also need to be in the browser.
3. BFF: Backend for Frontend
Concept
The BFF (Backend for Frontend) pattern delegates all secret management and authentication to a dedicated server application. The browser never sees tokens.
┌─────────────┐ Static assets ┌──────────────┐
│ Browser │ ◄──────────────────── │ BFF │
│ (React SPA)│ │ (Back end) │
│ │ ──── Requests ──────► │ │
│ │ ◄─── Cookie ───────── │ │
│ │ │ ┌──────────┐│
│ │ │ │ Tokens ││
│ │ │ │ Secrets ││
└─────────────┘ └──────────────┘
Basic flow
- The browser downloads static assets (HTML/CSS/JS) from the BFF
- The React SPA runs in the browser
- To authenticate, the SPA redirects to a login endpoint on the BFF
- The BFF verifies credentials and sets an encrypted identity cookie
- Subsequent requests automatically include this cookie
- The BFF intercepts requests and validates the cookie before contacting APIs
BFF advantages
- Tokens (access token, refresh token) stay on the server
- The OAuth2 client secret stays server-side
- CSRF protection possible via SameSite cookies
- Single entry point for the browser
4. Cookie Authentication
Overall architecture
Browser BFF (.NET / Node) Data Store
│ │ │
│── GET / ────►│ │
│◄── index.html ──────────────│ │
│── GET /account/login ──────►│ │
│◄── Login Page (HTML) ───────│ │
│── POST credentials ────────►│ │
│ │── Verify credentials ─►│
│ │◄── User data ───────────│
│◄── Set-Cookie: identity ────│ │
│── GET /api/data ───────────►│ (cookie auto-included) │
│ │── Validate cookie │
│◄── JSON data ───────────────│ │
useUser hook on the React side
// hooks/useUser.js
import { useState, useEffect } from "react";
import useGetRequest from "./useGetRequest";
export default function useUser() {
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuth] = useState(false);
const { get, loadingState } = useGetRequest();
useEffect(() => {
// slides=false: do not extend the cookie on every request
get("/account/getUserClaims?slides=false");
}, []);
useEffect(() => {
if (loadingState === "loaded") {
// If claims are returned, the user is authenticated
setIsAuth(true);
}
}, [loadingState]);
// Important: use window.location for real browser redirects
// Do NOT use the React router (that would be internal navigation)
const login = () => { window.location.href = "/account/login"; };
const logout = () => { window.location.href = "/account/logout"; };
const getName = () =>
user?.find(c => c.type === "name")?.value ?? "";
return { user, isAuthenticated, login, logout, getName, loadingState };
}
useGetRequest hook
// hooks/useGetRequest.js
import { useState } from "react";
export default function useGetRequest() {
const [loadingState, setLoadingState] = useState("idle");
const [data, setData] = useState(null);
const get = async (url) => {
setLoadingState("loading");
try {
const response = await fetch(url, { credentials: "include" });
if (response.status === 401) {
setLoadingState("loaded"); // not authenticated, but not an error
return;
}
const json = await response.json();
setData(json);
setLoadingState("loaded");
} catch (err) {
setLoadingState("errored");
}
};
return { get, data, loadingState };
}
Authenticator component
// components/Authenticator.jsx
import useUser from "../hooks/useUser";
export default function Authenticator() {
const { isAuthenticated, login, logout, getName } = useUser();
if (isAuthenticated) {
return (
<div>
<span>Welcome, {getName()}</span>
<button onClick={logout}>Logout</button>
</div>
);
}
return <button onClick={login}>Login</button>;
}
5. Same-Site Cookies and CSRF Protection
CSRF attack (Cross-Site Request Forgery)
A user logged into globomantics.com visits freetickets.com which contains:
<!-- Malicious site - freetickets.com -->
<form action="https://globomantics.com/proposals/approve" method="POST">
<input type="hidden" name="proposalId" value="4237" />
<button type="submit">Claim your free ticket!</button>
</form>
The browser automatically sends the globomantics.com identity cookie → the fraudulent request is accepted.
Protection via SameSite
| SameSite value | Behavior |
|---|---|
Strict | The cookie is never sent cross-site |
Lax | Cookie sent only on cross-site GET requests (top-level navigation) |
None | Cookie sent on all requests (must be paired with Secure) |
Set-Cookie: identity=...; SameSite=Strict; Secure; HttpOnly
HttpOnly: the cookie is not accessible via JavaScript (document.cookie).
Secure: the cookie is only sent over HTTPS.
Problem with SameSite and local development
If the front-end (port 3000) and back-end (port 7180) run on different ports → two different “sites” → SameSite cookies are not sent.
Solution: set up a reverse proxy so the browser only communicates with a single domain.
6. Deployment and Reverse Proxy
Architecture with Reverse Proxy
Browser BFF (port 7180) React Dev Server (port 3000)
│ │ │
│── GET / ────────────────►│ │
│ │ (no configured endpoint) │
│ │── Proxy ────────────────────►│
│◄── index.html ───────────│◄─────────────────────────────│
│── GET /api/houses ──────►│ │
│ │── Handles request │
│◄── JSON ─────────────────│ │
│── GET /components.js ───►│ │
│ │── Proxy ────────────────────►│
│◄── JS bundle ────────────│◄─────────────────────────────│
The browser only talks to the BFF → SameSite cookies work correctly.
YARP (Yet Another Reverse Proxy) in .NET
The BFF uses YARP to proxy unhandled requests to the React development server. In production, the BFF directly serves the React application build (wwwroot/).
7. OpenID Connect and OAuth2
When to use an Identity Provider?
When the organization has:
- Multiple front-ends (web + mobile) sharing the same credentials
- Multiple APIs that need to verify the user is authenticated
- A need for Single Sign-On (SSO) across applications
Key concepts
| Term | Definition |
|---|---|
| Identity Provider (IdP) | Centralized authentication service (e.g. IdentityServer, Auth0, Keycloak, Azure AD) |
| Client | Application requesting authentication (our BFF) |
| Resource Owner | The end user |
| Subject ID (sub) | Unique user identifier at the IdP |
| Scope | Basket of requested claims (e.g. openid, profile, email) |
| Identity Token | JWT containing the user’s identity claims |
| Access Token | JWT authorizing access to a specific API |
| Refresh Token | Long-lived token to obtain a new access token |
| Authorization Endpoint | IdP endpoint to initiate login |
| Token Endpoint | IdP endpoint to exchange a code for tokens |
Scopes
There are two types of scopes:
Identity Scopes — contain claims about the user:
openid: required, allows obtaining an identity tokenprofile: first name, last name, etc.email: email address- Custom scopes (e.g.
roles,department)
API Scopes — authorize access to an API:
- E.g.
conference_api,authapi - Produce an access token in addition to the identity token
8. Authentication Flows
Authorization Code Flow + PKCE (recommended)
Browser/BFF Identity Provider
│ │
│── 1. Redirect to /authorize ────►│
│ (client_id, scope, redirect_uri│
│ response_type=code, code_challenge)
│ │── Show login/consent
│◄── 2. Login Page ───────────────│
│── 3. POST credentials ──────────►│
│◄── 4. Redirect with ?code=XYZ ──│
│ │
│ (BFF receives the code) │
│── 5. POST /token ───────────────►│
│ (code, client_secret, │
│ code_verifier) │
│◄── 6. identity_token + │
│ access_token + │
│ refresh_token ────────────│
│ │
│── 7. Set identity cookie ──────► Browser
│ │
PKCE (Proof Key for Code Exchange)
Protection against the code substitution attack:
- The client generates a
code_verifier(random secret) - Computes
code_challenge = BASE64URL(SHA256(code_verifier)) - Sends
code_challengeto the Authorization Endpoint - The IdP associates this challenge with the issued code
- When exchanging the code at the Token Endpoint, the client sends the
code_verifier - The IdP verifies:
SHA256(code_verifier) == code_challenge→ impossible to forge without the original secret
Obsolete / less secure flows
| Flow | Status | Problem |
|---|---|---|
| Implicit Flow | ⚠️ Obsolete | Access token exposed in the browser (URL fragment) |
| Resource Owner Password | ❌ Not recommended | The application receives the password directly |
| Client Credentials | ✅ Machine-to-machine | No user involved |
Implicit Flow: do not use for SPAs. Replace with BFF + Authorization Code Flow + PKCE.
Token Refresh Flow
Browser BFF Identity Provider
│ │ │
│── Request ──►│ │
│ │ (Access token expired) │
│ │── POST /token ─────────►│
│ │ (grant_type=refresh_token,
│ │ refresh_token=XYZ, │
│ │ client_secret) │
│ │◄── New access_token ───│
│ │ + New refresh_token │
│ │ │
│◄── Response ─│ │
The browser never sees the refresh token. The BFF silently handles renewal.
9. Anatomy of a JWT
A JWT (JSON Web Token) — pronounced “jot” — is a Base64URL-encoded (not encrypted by default) token composed of three parts separated by dots:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 ← Header
.
eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlIiwic3ViIjoiYWxpY2UiLCJleHAiOjE3MDAwMDAwMDB9
← Payload (Claims)
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV... ← Signature
Standard claims in an Identity Token
| Claim | Meaning |
|---|---|
iss | Issuer — Identity Provider URL |
sub | Subject — unique user ID |
aud | Audience — recipient client ID |
exp | Expiration (seconds since Unix epoch) |
iat | Issued At — moment of issuance |
nbf | Not Before — not valid before this time |
nonce | Protection against replay attacks |
at_hash | Hash of part of the token (integrity) |
sid | Session ID |
auth_time | When authentication took place |
amr | Authentication Method Reference |
User claims (examples)
{
"sub": "alice",
"name": "Alice Smith",
"email": "alice@example.com",
"birthdate": "1990-01-15",
"role": "admin",
"department": "engineering"
}
Verification: Use jwt.io to decode and inspect a JWT.
10. Calls to an External API
The browser does not have access to the access token (it stays on the BFF). To call an external API:
Browser (React) BFF External API
│ │ │
│── GET /api/conf ►│ │
│ │── GET /conferences ──────►│
│ │ Authorization: Bearer │
│ │ <access_token> │
│ │◄── 200 JSON ──────────────│
│◄── 200 JSON ─────│ │
The BFF acts as an authenticated proxy: it receives the request from the SPA, adds the access token, forwards to the external API, and returns the response.
IdP configuration (IdentityServer)
// Config.cs - IdentityServer
new ApiResource("globomantics", "Globomantics API")
{
Scopes = { "conference_api" }
},
new Client
{
ClientId = "bff_client",
AllowedScopes = { "openid", "profile", "conference_api" }
}
11. BFF Styles
BFF styles comparison
| Style | Description | Advantages | Disadvantages |
|---|---|---|---|
| Single Host | Everything in a single back-end (BFF + data + SPA) | Simple, native SameSite cookies | Less flexible |
| Proxied Host | BFF proxies requests to the React dev server | Good for local dev | Proxy configuration required |
| Split Host | Front and back on different domains | Maximum flexibility | CORS required, complex SameSite cookies |
Recommended style for development
Browser ──► BFF (port 7180) ──► React Dev Server (port 3000)
↳ Handles API endpoints directly
↳ Proxies everything else to the front-end
12. Authorization in React
Fundamental principle
Never rely solely on front-end checks.
Authorization must be implemented both client-side and server-side.
The front-end check improves UX (hides unauthorized buttons), but the back-end check ensures real security since all browser code is modifiable.
Where to implement authorization
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ React SPA │ │ BFF │ │ External API │
│ │ │ │ │ │
│ UX checks │ │ Real security│ │ Real security│
│ (hide UI) │ │ (API guards) │ │ (JWT checks) │
└──────────────┘ └──────────────┘ └──────────────┘
↑ ↑ ↑
Improves UX Required Required
useAuthzRules hook (RBAC)
// hooks/useAuthzRules.js
import useUser from "./useUser";
import useAuthzData from "./useAuthzData";
export default function useAuthzRules() {
const { user } = useUser();
const { authzData } = useAuthzData(); // data from back-end
// Centralize ALL authorization rules here
// Name functions with "can" for clarity
const canEnterNewBid = () => {
const roleClaim = user?.find(c => c.type === "role");
return roleClaim?.value === "admin";
};
const canSeeAllBids = () => {
const displayBids = authzData?.find(d => d.type === "displayBids");
return displayBids?.value === "true";
};
const canManageProposals = () => {
const roleClaim = user?.find(c => c.type === "role");
return ["admin", "organizer"].includes(roleClaim?.value);
};
return { canEnterNewBid, canSeeAllBids, canManageProposals };
}
Best practice: Don’t create
isAdmin(). CreatecanDoSomething()— this decouples the business rule from the technical role and keeps everything centralized.
Usage in a component
// components/BidForm.jsx
import useAuthzRules from "../hooks/useAuthzRules";
export default function BidForm({ houseId }) {
const { canEnterNewBid, canSeeAllBids } = useAuthzRules();
// Early return if the user cannot see bids at all
if (!canSeeAllBids()) {
return null;
}
return (
<div>
<BidsList houseId={houseId} />
{/* Conditional rendering based on authorization rules */}
{canEnterNewBid() && (
<div>
<input type="number" placeholder="Your offer" />
<button>Submit</button>
</div>
)}
</div>
);
}
Protected Route
// components/ProtectedRoute.jsx
import { Navigate } from "react-router-dom";
import useUser from "../hooks/useUser";
export default function ProtectedRoute({ children, requiredRole }) {
const { isAuthenticated, user } = useUser();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (requiredRole) {
const roleClaim = user?.find(c => c.type === "role");
if (roleClaim?.value !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
}
return children;
}
// Usage in the router
// <Route path="/admin" element={
// <ProtectedRoute requiredRole="admin">
// <AdminPanel />
// </ProtectedRoute>
// } />
13. Authorization Data: Approaches
Approach 1: Claims in the cookie (Cookies Only)
// Authorization data directly in the identity cookie claims
// ⚠️ Watch cookie size - don't put too much data there
// hook useAuthzData.js - dedicated endpoint on the BFF
export default function useAuthzData() {
const { get, data, loadingState } = useGetRequest();
useEffect(() => {
// Protected endpoint - returns app-specific authorization data
get("/api/authorizationData");
}, []);
return { authzData: data, loadingState };
}
Rule: Only put essential claims in the identity cookie (at minimum the sub — Subject ID). Bulk authorization data should be retrieved via a separate endpoint.
Approach 2: Claims via OpenID Connect
Use for cross-organizational claims (shared across all applications):
name,email,department,employee_idrole(if the role applies to the whole organization)
Do not use for data specific to one application:
displayBids,canApproveProposals, etc.
Approach 3: Centralized Authorization API
React SPA → BFF → Authorization API → Returns authz data for this app
The Authorization API receives the access token (containing the sub) and an applicationId, then returns the authorization data for that user in that application.
14. RBAC with a Centralized Authorization API
Complete architecture (Solution 5)
Browser BFF (.NET) IdP (IdentityServer)
│ │ │
│── Login ────►│ │
│ │── /authorize ────────►│
│◄─ Login Page─│◄──────────────────────│
│── POST creds►│ │
│ │── /token ────────────►│
│ │◄─ identity_token │
│ │ access_token │
│ │ (scopes: openid, │
│ │ profile, roles, │
│ │ authapi) │
│◄─ Cookie ────│ │
Browser BFF Authorization API
│ │ │
│── GET /bids ►│ │
│ │── GET /authz/data ───►│
│ │ Bearer: access_token│
│ │◄─ { displayBids: true}│
│◄─ Bids data ─│ │
IdP configuration for roles
// Config.cs - IdentityServer
// Custom "roles" scope including the "role" claim
new IdentityResource("roles", new[] { "role" }),
// API resource for the Authorization API
new ApiResource("globoauthapi")
{
Scopes = { "authapi" }
},
// Client with required scopes
new Client
{
ClientId = "bff_client",
AllowedScopes = {
"openid", "profile",
"roles", // Identity scope → claim in identity token
"authapi" // API scope → access token for Authorization API
}
}
Test users
| User | Password | Role |
|---|---|---|
| alice | alice | Admin |
| bob | bob | Janitor |
15. Flow Diagrams
Complete JWT/OAuth2/OIDC flow in React (BFF Pattern)
sequenceDiagram
participant B as Browser (React SPA)
participant BFF as BFF (Back-end)
participant IDP as Identity Provider
participant API as External API
B->>BFF: GET / (initial access)
BFF-->>B: index.html + JS bundle
B->>BFF: Click Login
BFF->>IDP: Redirect /authorize<br/>(client_id, scope, code_challenge)
IDP-->>B: Login Page
B->>IDP: POST credentials
IDP->>BFF: Redirect ?code=XYZ
BFF->>IDP: POST /token (code + code_verifier + client_secret)
IDP-->>BFF: identity_token + access_token + refresh_token
BFF-->>B: Set-Cookie: identity (SameSite=Strict, HttpOnly)
B->>BFF: GET /api/data (cookie auto-included)
BFF->>BFF: Validate the cookie
BFF->>API: GET /data (Bearer: access_token)
API-->>BFF: 200 JSON
BFF-->>B: 200 JSON
Protected Routes in React
flowchart TD
A[Navigate to /admin] --> B{isAuthenticated?}
B -->|No| C[Redirect to /login]
B -->|Yes| D{Role required?}
D -->|No| E[Render component]
D -->|Yes| F{roleClaim === requiredRole?}
F -->|Yes| E
F -->|No| G[Redirect to /unauthorized]
Token Refresh Flow
sequenceDiagram
participant B as Browser
participant BFF as BFF
participant IDP as Identity Provider
B->>BFF: API Request
BFF->>BFF: Check access_token
alt Token expired
BFF->>IDP: POST /token<br/>(grant_type=refresh_token)
IDP-->>BFF: New access_token<br/>+ New refresh_token
BFF->>BFF: Store new tokens
end
BFF-->>B: API Response
RBAC in React components
flowchart TD
A[Bids Component] --> B[useAuthzRules]
B --> C[useUser - Identity Claims]
B --> D[useAuthzData - Authz Data]
C --> E{role === admin?}
D --> F{displayBids === true?}
E -->|Yes| G[Show new bid form]
E -->|No| H[Hide form]
F -->|Yes| I[Show bids list]
F -->|No| J[Return null - show nothing]
16. React Code Snippets
AuthContext (Context API for authentication state)
// context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from "react";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check authentication state on mount
fetch("/account/getUserClaims?slides=false", {
credentials: "include",
})
.then(res => {
if (res.status === 401) {
setIsAuthenticated(false);
return null;
}
return res.json();
})
.then(claims => {
if (claims) {
setUser(claims);
setIsAuthenticated(true);
}
})
.finally(() => setIsLoading(false));
}, []);
const login = () => { window.location.href = "/account/login"; };
const logout = () => { window.location.href = "/account/logout"; };
return (
<AuthContext.Provider value={{ user, isAuthenticated, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used inside an AuthProvider");
}
return context;
}
ProtectedRoute with AuthContext
// components/ProtectedRoute.jsx
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
export default function ProtectedRoute({ children, requiredRole }) {
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
// Remember the URL to redirect back after login
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requiredRole) {
const roleClaim = user?.find(c => c.type === "role");
if (roleClaim?.value !== requiredRole) {
return <Navigate to="/403" replace />;
}
}
return children;
}
JWT Storage — Comparison of approaches
// ❌ localStorage - Vulnerable to XSS attacks
localStorage.setItem("access_token", token);
const token = localStorage.getItem("access_token");
// ❌ sessionStorage - Also vulnerable to XSS
sessionStorage.setItem("access_token", token);
// ❌ Global JS variable - Accessible via XSS
window.__auth_token = token;
// ✅ With BFF - Token stays server-side, NEVER in the browser
// Browser uses an HttpOnly cookie managed by the BFF
// No token accessible via JavaScript
Recommendation: With the BFF pattern, never store tokens in the browser. The BFF manages tokens server-side and uses a secure HttpOnly cookie.
OAuth2 PKCE Flow (if SPA without BFF — not recommended but sometimes necessary)
// utils/pkce.js
// Generate a random code_verifier (43-128 characters)
export function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
// Compute code_challenge = BASE64URL(SHA256(code_verifier))
export async function generateCodeChallenge(codeVerifier) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest("SHA-256", data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
// Initiate the PKCE flow
export async function initiateAuthCodeFlow(config) {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = crypto.randomUUID();
// Temporarily store in sessionStorage (only the code_verifier and state)
sessionStorage.setItem("pkce_verifier", codeVerifier);
sessionStorage.setItem("pkce_state", state);
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
response_type: "code",
scope: "openid profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
nonce: crypto.randomUUID(),
});
window.location.href = `${config.authorizationEndpoint}?${params}`;
}
// Exchange code for tokens
export async function exchangeCodeForTokens(code, config) {
const codeVerifier = sessionStorage.getItem("pkce_verifier");
sessionStorage.removeItem("pkce_verifier");
const response = await fetch(config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
code,
code_verifier: codeVerifier,
}),
});
return response.json();
}
17. Reference Tables
Authentication strategies for React SPA
| Strategy | Security | Complexity | Use case |
|---|---|---|---|
| BFF + Cookie Auth | ✅✅✅ High | Medium | Internal apps, single API |
| BFF + OIDC/OAuth2 | ✅✅✅ High | High | Multi-APIs, SSO, mobile + web |
| SPA + PKCE (without BFF) | ✅✅ Medium | Medium | Public APIs, simple apps |
| Client-side API Key | ❌ None | Low | Never in production |
| In-memory credentials | ❌ None | Low | Never in production |
Comparison of OAuth2/OIDC flows
| Flow | Back-channel | Tokens in browser | Recommended |
|---|---|---|---|
| Authorization Code + PKCE | ✅ Yes (via BFF) | ❌ No | ✅ Yes |
| Authorization Code + PKCE (direct SPA) | ❌ No | ⚠️ Yes | ⚠️ If no BFF |
| Implicit | ❌ No | ❌ Yes (URL fragment) | ❌ No (obsolete) |
| Client Credentials | ✅ Yes | N/A | ✅ Machine-to-machine |
| Resource Owner Password | ✅ Yes | ❌ No | ❌ No (not recommended) |
Token storage
| Location | JS accessible | XSS vulnerable | CSRF vulnerable | Recommended |
|---|---|---|---|---|
| HttpOnly Cookie + SameSite=Strict | ❌ No | ❌ No | ❌ No | ✅ Yes |
| HttpOnly Cookie + SameSite=Lax | ❌ No | ❌ No | ⚠️ Partially | ⚠️ Acceptable |
| localStorage | ✅ Yes | ✅ Yes | ❌ No | ❌ No |
| sessionStorage | ✅ Yes | ✅ Yes | ❌ No | ❌ No |
| JS memory (variable) | ✅ Yes | ✅ Yes | ❌ No | ⚠️ Better than ls/ss |
Recommended claims by context
| Claim | Cookies Only | Centralized OIDC | Authorization API |
|---|---|---|---|
sub (Subject ID) | ✅ Always | ✅ Always | Via access token |
name, email | ✅ | ✅ | — |
role (organizational) | ✅ | ✅ | — |
department, employee_id | ✅ | ✅ | — |
displayBids (app-specific) | Via endpoint | ❌ | ✅ |
canApproveProposals | Via endpoint | ❌ | ✅ |
18. Best Practices and Key Takeaways
Security
- Never store secrets (tokens, client secret, API keys) in front-end code
- Always use a BFF to manage authentication and tokens
- Always implement authorization server-side, not just client-side
- Do not use Implicit Flow — replace it with Authorization Code Flow + PKCE
- Enable SameSite=Strict (or Lax) on identity cookies
- Use HttpOnly and Secure on all sensitive cookies
- Use PKCE even when not required (additional protection)
Architecture
- Use the BFF pattern as the single entry point for the browser
- Set up a reverse proxy to unify domains during development
- Centralize all authorization rules in a
useAuthzRuleshook - Create
canDoX()functions rather thanisAdmin()— decouple the rule from the role - Use a standard Identity Provider (IdentityServer, Auth0, Keycloak, Azure AD) — never implement your own token management
Performance
- Avoid putting too many claims in the identity cookie (browser size issues)
- Create a dedicated endpoint for bulk authorization data
- Use React Query to cache calls to
useAuthzDataanduseUser - The
Subject ID (sub)must always be present in the cookie as the minimal identifier
Standards used
| Standard | Role |
|---|---|
| OpenID Connect (OIDC) | Authentication, identity tokens |
| OAuth 2.0 | Authorization, access tokens |
| JWT (RFC 7519) | Token format |
| PKCE (RFC 7636) | Securing the Authorization Code Flow |
| PAR (OIDC 2.0) | Pushed Authorization Requests (more secure) |
Security checklist
- BFF in place to manage tokens
- HttpOnly + Secure + SameSite cookies configured
- CSRF protection verified (SameSite Strict or Lax)
- Authorization Code Flow + PKCE in use
- Implicit Flow removed / not used
- Authorization validated server-side for each endpoint
- No tokens in localStorage or sessionStorage
- Identity Provider up to date (supports PKCE)
- Authorization rules centralized in
useAuthzRules - Caching in place for authorization calls (React Query)
- No secrets in front-end source code (verify with
git grep)
Search Terms
authentication · authorization · react · typescript · frontend · development · flow · bff · claims · api · architecture · oauth2 · recommended · token · approach · comparison · flows · hook · jwt · pkce · proxy · rbac · reverse · approaches