Advanced

Authentication and Authorization in React

AuthN vs AuthZ, the BFF pattern, cookie auth, OIDC/OAuth2, JWTs and RBAC in React.

Table of Contents

  1. Core Concepts: AuthN vs AuthZ
  2. Why the Browser Cannot Keep Secrets
  3. BFF: Backend for Frontend
  4. Cookie Authentication
  5. Same-Site Cookies and CSRF Protection
  6. Deployment and Reverse Proxy
  7. OpenID Connect and OAuth2
  8. Authentication Flows
  9. Anatomy of a JWT
  10. Calls to an External API
  11. BFF Styles
  12. Authorization in React
  13. Authorization Data: Approaches
  14. RBAC with a Centralized Authorization API
  15. Flow Diagrams
  16. React Code Snippets
  17. Reference Tables
  18. 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

AttackMechanismImpact
DevTools (F12)Reading source code, Network tab, Application tabTheft of secrets, API keys
XSS (Cross-Site Scripting)Injection of malicious JavaScriptToken theft from localStorage
CSRFCross-site requests using existing cookiesUnauthorized actions
Code substitutionInterception of Authorization Code Flow codeFraudulent 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

  1. The browser downloads static assets (HTML/CSS/JS) from the BFF
  2. The React SPA runs in the browser
  3. To authenticate, the SPA redirects to a login endpoint on the BFF
  4. The BFF verifies credentials and sets an encrypted identity cookie
  5. Subsequent requests automatically include this cookie
  6. 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

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 valueBehavior
StrictThe cookie is never sent cross-site
LaxCookie sent only on cross-site GET requests (top-level navigation)
NoneCookie 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

TermDefinition
Identity Provider (IdP)Centralized authentication service (e.g. IdentityServer, Auth0, Keycloak, Azure AD)
ClientApplication requesting authentication (our BFF)
Resource OwnerThe end user
Subject ID (sub)Unique user identifier at the IdP
ScopeBasket of requested claims (e.g. openid, profile, email)
Identity TokenJWT containing the user’s identity claims
Access TokenJWT authorizing access to a specific API
Refresh TokenLong-lived token to obtain a new access token
Authorization EndpointIdP endpoint to initiate login
Token EndpointIdP 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 token
  • profile: 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

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:

  1. The client generates a code_verifier (random secret)
  2. Computes code_challenge = BASE64URL(SHA256(code_verifier))
  3. Sends code_challenge to the Authorization Endpoint
  4. The IdP associates this challenge with the issued code
  5. When exchanging the code at the Token Endpoint, the client sends the code_verifier
  6. The IdP verifies: SHA256(code_verifier) == code_challenge → impossible to forge without the original secret

Obsolete / less secure flows

FlowStatusProblem
Implicit Flow⚠️ ObsoleteAccess token exposed in the browser (URL fragment)
Resource Owner Password❌ Not recommendedThe application receives the password directly
Client Credentials✅ Machine-to-machineNo 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

ClaimMeaning
issIssuer — Identity Provider URL
subSubject — unique user ID
audAudience — recipient client ID
expExpiration (seconds since Unix epoch)
iatIssued At — moment of issuance
nbfNot Before — not valid before this time
nonceProtection against replay attacks
at_hashHash of part of the token (integrity)
sidSession ID
auth_timeWhen authentication took place
amrAuthentication 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

StyleDescriptionAdvantagesDisadvantages
Single HostEverything in a single back-end (BFF + data + SPA)Simple, native SameSite cookiesLess flexible
Proxied HostBFF proxies requests to the React dev serverGood for local devProxy configuration required
Split HostFront and back on different domainsMaximum flexibilityCORS required, complex SameSite cookies
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(). Create canDoSomething() — 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

// 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_id
  • role (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

UserPasswordRole
alicealiceAdmin
bobbobJanitor

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.

// 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

StrategySecurityComplexityUse case
BFF + Cookie Auth✅✅✅ HighMediumInternal apps, single API
BFF + OIDC/OAuth2✅✅✅ HighHighMulti-APIs, SSO, mobile + web
SPA + PKCE (without BFF)✅✅ MediumMediumPublic APIs, simple apps
Client-side API Key❌ NoneLowNever in production
In-memory credentials❌ NoneLowNever in production

Comparison of OAuth2/OIDC flows

FlowBack-channelTokens in browserRecommended
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✅ YesN/A✅ Machine-to-machine
Resource Owner Password✅ Yes❌ No❌ No (not recommended)

Token storage

LocationJS accessibleXSS vulnerableCSRF vulnerableRecommended
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
ClaimCookies OnlyCentralized OIDCAuthorization API
sub (Subject ID)✅ Always✅ AlwaysVia access token
name, email
role (organizational)
department, employee_id
displayBids (app-specific)Via endpoint
canApproveProposalsVia 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 useAuthzRules hook
  • Create canDoX() functions rather than isAdmin() — 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 useAuthzData and useUser
  • The Subject ID (sub) must always be present in the cookie as the minimal identifier

Standards used

StandardRole
OpenID Connect (OIDC)Authentication, identity tokens
OAuth 2.0Authorization, 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

Interested in this course?

Contact us to book it or get a custom training plan for your team.