Back to blog
Guides

How to Add SSO Login to a React App (OIDC + PKCE Guide)

Your React app has email-and-password login, and a customer just asked for SSO in the security review. This guide wires single sign-on into an existing React app using Blocks IAM as the identity provider, with the authorization code flow and PKCE. The flow is standard OpenID Connect, so everything transfers if your provider is Okta, Entra ID, or Keycloak instead; only the endpoints change. What you'll have by the end: a login button that redirects to Blocks IAM, a callback route that exchanges the code for tokens, and a guard that keeps unauthenticated visitors out.

10 min read
SSO loginOIDC + PKCEBlocks IAM

SSO vs OAuth, in one paragraph

SSO is the user-facing outcome: sign in once, reach every app the company runs. OpenID Connect (OIDC) is the protocol that delivers it on top of OAuth 2.0, which by itself only grants access to APIs. The difference is the ID token, a signed JWT that tells your app who signed in. When someone says "add SSO to a React app," nine times out of ten they mean speak OIDC to a provider. Your React app becomes an OIDC client of that provider, and users authenticate there instead of against your database.

How Blocks IAM models SSO

Blocks IAM is the identity service of the Blocks platform, and it plays two roles at once. It is an IdP that speaks OIDC end to end: authorization and token endpoints, tenant-scoped discovery, JWKS key publishing, and client registration. It is also the place where users, organizations, roles, and permission grants live, which means the tokens it mints carry more than an identity. Roles and organization context are re-resolved at mint time, so a permission revoked after login is already absent from the next token.

Multi-tenancy shows up in the URLs. Discovery lives at /{tenant_id}/.well-known/openid-configuration, and API calls carry a x-blocks-key header that pins the tenant. One IAM instance serves many tenants, and your client registration, your users, and your tokens all stay inside yours.

Public clients get first-class treatment. The discovery document advertises token_endpoint_auth_methods_supported including none, which is the SPA case: no client secret ships with a browser app. PKCE covers that gap, and IAM enforces it. Client registrations default to RequirePkce: true, and a wrong verifier at the token endpoint comes back as invalid_grant with an explicit PKCE code_verifier is invalid error.

Prerequisites

  • A React app with React Router (v6 or later). This guide assumes Vite; CRA works the same.

  • A Blocks tenant (project) you control, with access to its IAM service.

  • Node 18+ for the dev server.

You need three values before writing code: the client ID IAM issues when you register your app, the redirect URI you register for it, and your tenant's IAM base URL.

Step 1: Register your app as an OIDC client

Registration happens in the Blocks portal, or over the IAM API if you script it. The API is an upsert at POST /api/oidc-clients, guarded by the blocks-iam::iam::mutate-oidc-clients permission. The fields that matter for a React SPA:

  • Redirect URIs: https://your-app.example.com/callback

  • Allowed scopes: openid profile email

  • RequirePkce: leave it true (the default)

  • RequireMfa: optional; enforce it per client if the security review demands MFA

IAM supports openid, profile, email, and offline_access (the refresh token scope). The response returns your client ID. Save the environment-specific values:

# .env.local
VITE_BLOCKS_OIDC_CLIENT_ID=your-client-id
VITE_BLOCKS_OIDC_REDIRECT_URI=https://your-app.example.com/callback
VITE_IAM_BASE_URL=https://iam.your-tenant.example.com

Step 2: Read the endpoints from discovery

Every other URL your code needs comes from one document. Fetch {VITE_IAM_BASE_URL}/{tenant_id}/.well-known/openid-configuration and you get the authorization_endpoint, token_endpoint, jwks_uri, supported scopes, and the supported PKCE methods (S256) in a single JSON response. IAM caches it for an hour, and the JWKS document behind jwks_uri for a day.

// src/auth/discovery.ts
export async function loadDiscovery(iamBaseUrl: string, tenantId: string) {
  const res = await fetch(`${iamBaseUrl}/${tenantId}/.well-known/openid-configuration`);
  if (!res.ok) throw new Error(`discovery failed: ${res.status}`);
  const doc = await res.json();
  return {
    authorizationEndpoint: doc.authorization_endpoint,
    tokenEndpoint: doc.token_endpoint,
    jwksUri: doc.jwks_uri,
  };
}

Hardcoding endpoint paths is the mistake this step exists to prevent. If a provider moves or renames an endpoint, discovery-absent code breaks at redirect time with an error page, while discovery-driven code keeps working.

Step 3: Generate PKCE values and redirect to IAM

PKCE ties the browser redirect to the token request with a random secret, so an intercepted authorization code is worthless on its own. Your app generates a code_verifier, sends its SHA-256 hash as the code_challenge, and redeems the verifier after the redirect returns.

// src/auth/pkce.ts
function base64Url(bytes: Uint8Array): string {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

export async function createPkcePair() {
  const verifierBytes = new Uint8Array(32);
  crypto.getRandomValues(verifierBytes);
  const verifier = base64Url(verifierBytes);
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
  return { verifier, challenge: base64Url(new Uint8Array(digest)) };
}

The login click stores the verifier and a state value, then sends the browser to IAM's authorize endpoint:

// src/auth/login.ts
import { createPkcePair } from './pkce';
import { loadDiscovery } from './discovery';

export async function login() {
  const { verifier, challenge } = await createPkcePair();
  const state = crypto.randomUUID();

  sessionStorage.setItem('pkce_verifier', verifier);
  sessionStorage.setItem('oidc_state', state);

  const iam = import.meta.env.VITE_IAM_BASE_URL;
  const { authorizationEndpoint } = await loadDiscovery(iam, YOUR_TENANT_ID);

  const params = new URLSearchParams({
    client_id: import.meta.env.VITE_BLOCKS_OIDC_CLIENT_ID,
    redirect_uri: import.meta.env.VITE_BLOCKS_OIDC_REDIRECT_URI,
    response_type: 'code',
    scope: 'openid profile email offline_access',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  window.location.href = `${authorizationEndpoint}?${params}`;
}

state defends against forged redirects. If the callback receives a value the app did not issue, the response is dropped. IAM also accepts prompt and nonce here, and its authorize endpoint defaults code_challenge_method to S256 when you send a challenge without a method.

Step 4: Add the callback route

IAM redirects back to your registered URI with ?code=...&state=.... A route component picks that up, validates the state, and posts the code plus verifier to the token endpoint it read from discovery:

// src/routes/Callback.tsx
import { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';

export function Callback() {
  const navigate = useNavigate();
  const [params] = useSearchParams();
  const exchanging = useRef(false);

  useEffect(() => {
    const code = params.get('code');
    const state = params.get('state');
    const savedState = sessionStorage.getItem('oidc_state');
    const verifier = sessionStorage.getItem('pkce_verifier');

    if (!code || state !== savedState || exchanging.current) return;
    exchanging.current = true;

    (async () => {
      const { tokenEndpoint } = await loadDiscovery(
        import.meta.env.VITE_IAM_BASE_URL,
        YOUR_TENANT_ID,
      );

      const body = new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: import.meta.env.VITE_BLOCKS_OIDC_CLIENT_ID,
        code,
        redirect_uri: import.meta.env.VITE_BLOCKS_OIDC_REDIRECT_URI,
        code_verifier: verifier,
      });

      const res = await fetch(tokenEndpoint, { method: 'POST', body });

      if (!res.ok) {
        navigate('/login', { replace: true });
        return;
      }

      const tokens = await res.json();
      sessionStorage.setItem('access_token', tokens.access_token);
      sessionStorage.setItem('refresh_token', tokens.refresh_token);
      sessionStorage.removeItem('pkce_verifier');
      sessionStorage.removeItem('oidc_state');
      navigate('/', { replace: true });
    })();
  }, [params, navigate]);

  return <p>Signing you in…</p>;
}

The response carries an access_token for API calls, an id_token naming the user, and a refresh_token because the scope requested offline_access. The ref guard keeps React 18's double effect invocation in development from firing the exchange twice. Wire the route:

<Route path="/callback" element={<Callback />} />

This is the same shape Blocks ships in its own reference client. The blocks-construct-react app has a login component that redirects with client_id, redirect_uri, response_type=code, and scope=openid, and a /oidc callback route that exchanges the code and lands the user in the app.

Step 5: Protect routes and refresh tokens

A small context exposes the session, and a RequireAuth guard gates private pages:

// src/auth/AuthContext.tsx
const AuthContext = createContext<{ token: string | null }>({ token: null });

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [token, setToken] = useState(sessionStorage.getItem('access_token'));

  useEffect(() => {
    const sync = () => setToken(sessionStorage.getItem('access_token'));
    window.addEventListener('auth-change', sync);
    return () => window.removeEventListener('auth-change', sync);
  }, []);

  return <AuthContext.Provider value={{ token }}>{children}</AuthContext.Provider>;
}

export function RequireAuth({ children }: { children: React.ReactNode }) {
  const { token } = useContext(AuthContext);
  if (!token) return <Navigate to="/login" replace />;
  return children;
}

When the access token expires, send the refresh token back to the same token endpoint with grant_type=refresh_token. IAM resolves the refresh session server-side, rejects the exchange if the token was revoked or the account is now locked or gone, and re-resolves roles and organization context before minting the new pair. Keep tokens in memory or sessionStorage; avoid localStorage unless you have thought through the XSS exposure. Log out through IAM's logout endpoint so it revokes the refresh token server-side, not by only clearing local state.

The shortcut: let the scaffold do it

If the React app does not exist yet, the blocks CLI scaffolds one with this entire flow already wired. Run blocks new web inside a Blocks project and you get an app with the provider redirect, the callback handling, an AuthProvider, RequireAuth guards, and token refresh in place. The scaffold's local HTTPS story matters here: hosted login needs the session cookie, and plain HTTP and localhost never receive it. The scaffold generates a certificate for the project's real domain (npm run cert), so SSO works in local development against the same IAM your production app uses.

Troubleshooting the five errors everyone hits

redirect_uri_mismatch. The redirect URI in the authorize request must match the registered one character for character: trailing slash, scheme, port, all of it. This is the single most common OIDC error. Check http vs https first.

invalid_grant on the token exchange. Either the code was already used (effects running twice in strict mode), the verifier does not match the challenge, or the code expired. IAM's variant names the PKCE case directly: PKCE code_verifier is invalid. Regenerate the pair per attempt.

Cookie problems on localhost. IAM's login page sets its session cookie over https. Chrome treats localhost specially, Safari less so, and the scaffold's certificate-plus-hosts-entry approach exists because plain HTTP and localhost never receive the session cookie. For manual setups, mkcert against a hosts entry pointing your project domain at 127.0.0.1 is the reliable path. IAM's own local development does exactly this, serving HTTPS on dev-iam.blocksdevelopers.com when cert and key environment variables are set.

Clock skew. If token validation fails minutes after a successful login, machine clocks have drifted more than the tolerance. IAM's signature windows are tight (its delegation flow allows ±60 seconds), so sync NTP on the machine doing validation.

Infinite redirect loop between app and provider. A stale session cookie at IAM can bounce the user straight back with a code your app rejects. Or the callback navigates to a guarded route while tokens are still unset. Add prompt=login while debugging, which forces fresh authentication each round trip.

Using a different provider

Everything above is standard OIDC, so the shape survives a provider swap. Register a public client, read the endpoints from that provider's discovery document, and run the same PKCE loop. Okta uses /authorize, Keycloak uses /protocol/openid-connect/auth, and both publish the rest in their discovery documents. What you lose leaving Blocks is the platform around the flow: MFA enforcement per client, roles and organizations in the tokens, and the session that already spans every Blocks service in the tenant. If you are comparing identity providers instead of wiring one up, the Auth0 alternatives comparison covers that market, and the Blocks IAM docs have the full client-registration reference.

FAQ

Do I need a library for this? Not for the flow itself; the code above is complete. Libraries earn their keep on the edges: silent renewal, session management across tabs, and validated ID tokens against the JWKS endpoint. If you use one, pick one that speaks raw OIDC and does not hide the token responses from you.

Where should tokens live in a React app? In memory, with the refresh token in a same-site cookie or refreshed through a backend-for-frontend. sessionStorage survives reloads but is readable by any script on the page. localStorage adds the same exposure with more persistence, so treat both as a tradeoff you made on purpose.

Does SSO with Blocks IAM cover my other apps too? Yes, that is the point of the IdP session living at the platform. Any app registered as an OIDC client in the tenant rides the same login, and sign-out can propagate to registered apps through IAM's front-channel and back-channel logout support. Your app holds tokens; it never holds the session.

Can I test SSO locally without a domain? Mostly no, and this surprises people. The login session cookie requires https and a real host, so the reliable path is an https dev server with a hosts entry pointing your project domain at 127.0.0.1. The Blocks scaffold automates this with npm run cert. Register that exact URI.