In this article
October 8, 2026
October 8, 2026

Ship a React SPA with AuthKit to production

Take a Vite and React app from localhost to production with httpOnly cookie refresh, a custom Authentication API domain, preview deployments, and a Node API that verifies every request.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Getting a React single page app (SPA) to sign users in on localhost takes about ten minutes with @workos-inc/authkit-react. Getting the same app to keep users signed in on production, in every browser, on preview deployments, and while it calls your own API takes more thought.

Most of the problems show up after launch, and they look like this:

  • Users are signed out every few minutes in Safari or Firefox, but not in Chrome.
  • Refresh requests fail with Refresh token not found and a SameSite warning in the console.
  • Login works on main but fails on every Vercel preview.
  • The backend accepts any request that has a token in it, because nothing verifies the token.

This tutorial walks through the full path. You will build a Vite and React app that signs users in with AuthKit, calls an Express API with an access token, and verifies that token on the server. Then you will take it to production the right way.

What you will build

What you will build
Piece Stack Job
Frontend Vite, React, @workos-inc/authkit-react Signs users in, keeps the session fresh, calls the API
Backend Node.js, Express, jose Verifies the access token and enforces organization and permission checks
Identity WorkOS AuthKit Hosted sign-in, sessions, organizations, roles

Prerequisites

  • A WorkOS account
  • Node.js 20 or later
  • A domain you control for the production step

Want the fastest start? Run npx workos@latest integrate from your project root. The WorkOS CLI detects your framework, signs you in to WorkOS in the browser, installs the matching AuthKit SDK, adds the provider and callback code, and sets your redirect URI and CORS origins in the dashboard. That gets you to a working sign-in on localhost in a few minutes. This guide picks up where the CLI stops: verifying tokens in your own API, custom domains, preview deployments, and production settings. Steps 1 and 2 show what the CLI sets up, so you can follow them by hand or use them to review its changes.

How a client-only AuthKit session works

Before writing code, it helps to know where each token lives, because almost every production bug comes from this.

When a user signs in, the SDK runs the authorization code flow with PKCE directly from the browser. No client secret is involved, which is why this works in an SPA. The result is two tokens:

  • Access token: a short-lived JWT. Your frontend sends it to your API, and your API verifies it.
  • Refresh token: a long-lived credential used to get new access tokens. Where it is stored depends on one setting, devMode.
Where the refresh token lives
devMode on devMode off (production)
Refresh token storage localStorage httpOnly cookie set by the Authentication API
Readable by JavaScript Yes No
Needs a custom Authentication API domain No Yes, on the same site as your app
Where to use it Local development and staging Production

‍

The second column is what you want in production. An httpOnly cookie cannot be read by injected scripts, so a cross-site scripting bug in your app does not hand an attacker a long-lived refresh token.

The catch is that browsers only send that cookie when the Authentication API and your app are on the same site. That requirement drives most of the setup in this guide.

Sequence diagram of a client-only AuthKit session across four parties: the browser at app.example.com, the Authentication API at auth-api.example.com, hosted AuthKit at auth.example.com, and your API at api.example.com. 1: signIn() redirects the browser to the hosted sign-in page. 2: AuthKit redirects back with an authorization code. 3: The browser exchanges the code and PKCE verifier with the Authentication API. 4: It returns an access token as JSON and a refresh token as an httpOnly cookie. 5: The browser calls GET /api/projects with the access token as a Bearer token. 6: Your API verifies the signature, issuer and expiry, then org_id and permissions. 7: It returns 200 OK with data scoped to the organization. Before the access token expires, 8: the browser sends a refresh request and the cookie goes along automatically because both domains share example.com. 9: The Authentication API returns a new access token and a rotated refresh cookie. A shaded area around the browser and Authentication API notes that sharing the site example.com makes the refresh cookie first party.

Step 1: Configure the WorkOS dashboard for local development

In the WorkOS dashboard, select your staging environment and set up the following.

  1. Redirect URI: add http://localhost:5173/callback. Staging environments allow http and localhost.
  2. Sign-in endpoint (Initiate login URI): add http://localhost:5173/login. AuthKit sends users here when a sign-in does not start in your app, for example from a bookmarked sign-in page.
  3. Sign-out redirect: add http://localhost:5173. Without one, signOut() returns an error.
  4. Allowed web origins (CORS): add http://localhost:5173. The browser calls the WorkOS API directly, so it needs to be allowed.

Copy your Client ID. You will not need an API key anywhere in the frontend.

Using the CLI instead

If you prefer, the CLI can do most of steps 1 and 2 for you. Vite serves on port 5173, while the CLI defaults to a redirect URI on port 3000, so pass yours explicitly:

  
npx workos@latest integrate --redirect-uri http://localhost:5173/callback
  

Review the result with git diff before you commit. Two things to check in a client-only app:

  • No secrets in the bundle. The CLI saves keys to .env.local. Vite exposes every variable that starts with VITE_ to the browser, so only the Client ID should carry that prefix. Your WorkOS API key must never appear in frontend code. In this architecture, only the backend would ever need one, and the API in step 4 does not.
  • Dashboard settings it does not cover. Add the sign-in endpoint and sign-out redirect from the list above if they are not already set.

Step 2: Add AuthKit to the React app

Create the app and install the SDK.

  
npm create vite@latest acme-app -- --template react-ts
cd acme-app
npm install @workos-inc/authkit-react
  

Add your environment variables to .env.local.

  
VITE_WORKOS_CLIENT_ID=client_123
VITE_API_URL=http://localhost:3001
# Leave empty locally. Set to your custom Authentication API domain in production.
VITE_WORKOS_API_HOSTNAME=
  

Wrap the app in AuthKitProvider.

  
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthKitProvider } from "@workos-inc/authkit-react";
import App from "./App";

const apiHostname = import.meta.env.VITE_WORKOS_API_HOSTNAME || undefined;

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <AuthKitProvider
      clientId={import.meta.env.VITE_WORKOS_CLIENT_ID}
      apiHostname={apiHostname}
      onRefreshFailure={({ signIn }) => signIn()}
    >
      <App />
    </AuthKitProvider>
  </StrictMode>
);
  

Two details matter here:

  • No devMode prop. The SDK turns devMode on automatically on localhost and 127.0.0.1, so local development works without a custom domain. You will set it explicitly only for deployed staging environments (step 5).
  • onRefreshFailure. If a refresh fails, for example because the session was revoked, the user is sent back to sign in instead of being left with a broken page.

Now use the useAuth hook to sign users in and out.

  
// src/App.tsx
import { useAuth } from "@workos-inc/authkit-react";
import { Projects } from "./Projects";

export default function App() {
  const { isLoading, user, organizationId, role, signIn, signOut } = useAuth();

  if (isLoading) return <p>Loading...</p>;

  if (!user) {
    return <button onClick={() => signIn()}>Sign in</button>;
  }

  return (
    <main>
      <header>
        <span>
          {user.email} ({role ?? "no role"} in {organizationId ?? "no organization"})
        </span>
        <button onClick={() => signOut()}>Sign out</button>
      </header>
      <Projects />
    </main>
  );
}
  

Run npm run dev, open http://localhost:5173, and sign in. You should see your email, and the user should appear under Users in the dashboard.

!!Validate redirects. If you pass a returnTo value through signIn({ state }), check it against your own origin before navigating to it. The state value is not integrity-protected.!!

Step 3: Call your API with the access token

The frontend should never cache the access token in a variable or in storage of its own. Ask the SDK for it right before each request. getAccessToken() returns the current token, or refreshes it first if it has expired.

  
// src/useApi.ts
import { useAuth } from "@workos-inc/authkit-react";

export function useApi() {
  const { getAccessToken } = useAuth();

  return async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
    const token = await getAccessToken();
    const res = await fetch(`${import.meta.env.VITE_API_URL}${path}`, {
      ...init,
      headers: { ...init.headers, Authorization: `Bearer ${token}` },
    });
    if (!res.ok) throw new Error(`API error ${res.status}`);
    return res.json() as Promise<T>;
  };
}
  

Getting a fresh token on every call also fixes a subtle bug in long-lived tabs. Browsers throttle timers in background tabs, so a scheduled refresh can run late. A token fetched at request time is always valid, no matter how long the tab sat in the background.

  
// src/Projects.tsx
import { useEffect, useState } from "react";
import { useApi } from "./useApi";

type Project = { id: string; name: string };

export function Projects() {
  const api = useApi();
  const [projects, setProjects] = useState<Project[]>([]);

  useEffect(() => {
    api<Project[]>("/api/projects").then(setProjects).catch(console.error);
  }, []);

  return (
    <ul>
      {projects.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}
  

Step 4: Verify the token in a Node API

Every request to your API must be verified on the server. The role and permissions values from useAuth() are useful for showing or hiding buttons, but they are not a security boundary. Anyone can call your API directly.

Create the API.

  
mkdir acme-api && cd acme-api
npm init -y
npm install express cors jose
npm install -D typescript tsx @types/express @types/cors
  

AuthKit access tokens are JWTs signed with keys published at a JWKS endpoint for your client. The middleware below checks the signature, issuer and expiry, then exposes the claims your routes need.

  
// src/auth.ts
import type { Request, Response, NextFunction } from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";

const clientId = process.env.WORKOS_CLIENT_ID!;
const apiHostname = process.env.WORKOS_API_HOSTNAME || "api.workos.com";

// Keys are fetched once and cached; jose refetches on unknown key IDs.
const JWKS = createRemoteJWKSet(
  new URL(`https://${apiHostname}/sso/jwks/${clientId}`)
);
const issuer = `https://${apiHostname}/`;

export type AuthContext = {
  userId: string;
  sessionId: string;
  organizationId?: string;
  role?: string;
  permissions: string[];
};

export async function requireAuth(req: Request, res: Response, next: NextFunction) {
  const [scheme, token] = (req.headers.authorization ?? "").split(" ");
  if (scheme !== "Bearer" || !token) {
    return res.status(401).json({ error: "missing_token" });
  }

  try {
    const { payload } = await jwtVerify(token, JWKS, { issuer });
    res.locals.auth = {
      userId: payload.sub!,
      sessionId: payload.sid as string,
      organizationId: payload.org_id as string | undefined,
      role: payload.role as string | undefined,
      permissions: (payload.permissions as string[] | undefined) ?? [],
    } satisfies AuthContext;
    next();
  } catch {
    return res.status(401).json({ error: "invalid_token" });
  }
}

export function requirePermission(permission: string) {
  return (_req: Request, res: Response, next: NextFunction) => {
    const auth = res.locals.auth as AuthContext;
    if (!auth.permissions.includes(permission)) {
      return res.status(403).json({ error: "forbidden" });
    }
    next();
  };
}
  

By default, the iss claim in AuthKit access tokens is https://api.workos.com/. When you configure a custom Authentication API domain (step 5), the issuer becomes that domain instead. That is why the middleware builds both the issuer and the JWKS URL from WORKOS_API_HOSTNAME: leave it unset locally, and set it to your custom domain in production. If the two do not match, jwtVerify rejects every token with an issuer error. See Sessions for the full list of claims.

Now use it. Scope every query to the organization in the token, not to an organization ID sent by the client.

  
// src/server.ts
import express from "express";
import cors from "cors";
import { requireAuth, requirePermission, type AuthContext } from "./auth";

const app = express();
app.use(express.json());
app.use(cors({ origin: process.env.APP_ORIGIN, allowedHeaders: ["Authorization", "Content-Type"] }));

app.get("/api/projects", requireAuth, async (_req, res) => {
  const { organizationId } = res.locals.auth as AuthContext;
  if (!organizationId) return res.status(403).json({ error: "no_organization" });

  const projects = await db.projects.findMany({ where: { organizationId } });
  res.json(projects);
});

app.delete(
  "/api/projects/:id",
  requireAuth,
  requirePermission("projects:delete"),
  async (req, res) => {
    const { organizationId } = res.locals.auth as AuthContext;
    await db.projects.delete({ where: { id: req.params.id, organizationId } });
    res.status(204).end();
  }
);

app.listen(3001);
  

Because the API uses an Authorization header instead of cookies, it does not need to share a site with WorkOS, and you do not need CSRF protection for these routes. The only cookie in this design is the refresh cookie, and only the Authentication API reads it.

Step 5: Set up custom domains for production

This is the step that most teams get wrong. WorkOS offers four custom domains, they do different jobs, and only one of them is required for cookie refresh to work.

The four WorkOS custom domains
Custom domain Replaces What it changes Needed for this app?
Authentication API api.workos.com Where the SDK exchanges codes and refreshes tokens, and who sets the refresh cookie Required for httpOnly cookie refresh
AuthKit *.authkit.app The address users see on the hosted sign-in pages Recommended, so users never leave your domain to sign in
Email workos-mail.com The sender of Magic Auth, verification, password reset and invitation emails Recommended before production traffic, since delivery from the shared domain is best effort
Admin Portal setup.workos.com The address your customers' IT admins see when they set up SSO or directory sync Optional, only if you use Admin Portal

‍

A few rules apply to all of them:

  • Production only. Staging environments always use WorkOS domains.
  • Paid add-on. Check pricing before you plan around them.
  • One team per domain. A custom domain can only be configured by one WorkOS team at a time.
  • Same setup flow. Each one is a CNAME (three for email) that you add at your DNS provider. On Cloudflare, set them to DNS only, not proxied.

The rest of this step focuses on the Authentication API domain, because it is the one that decides whether refresh works. There are three hostnames involved and they are easy to mix up.

The three hostnames in a production setup
Hostname (example) What it is Configured where
app.example.com Your React app Your hosting provider
auth.example.com The hosted AuthKit sign-in pages WorkOS dashboard, Domains, AuthKit domain
auth-api.example.com The Authentication API that issues tokens and sets the refresh cookie WorkOS dashboard, Domains, Authentication API domain, then apiHostname in the SDK

‍

The value you pass to apiHostname is the Authentication API domain, not the hosted AuthKit domain. Setting apiHostname to auth.example.com is a common mistake.

The rule that makes cookie refresh work is simple: the Authentication API domain must be on the same site as your app. auth-api.example.com and app.example.com share example.com, so the browser treats the refresh cookie as first party and sends it. If your app is on app.example.com and your Authentication API is on auth.example-labs.com, the cookie is third party, and browsers that block third-party cookies will drop it. That shows up as Refresh token not found and a SameSite warning.

To set up the Authentication API domain:

  1. Select your production environment and open Domains.
  2. Click Configure authentication API domain and enter auth-api.example.com.
  3. Add the CNAME record the dashboard shows at your DNS provider. On Cloudflare, set it to DNS only, not proxied.
  4. Wait for verification. WorkOS keeps checking for up to 72 hours.
  5. Set VITE_WORKOS_API_HOSTNAME=auth-api.example.com in the frontend and WORKOS_API_HOSTNAME=auth-api.example.com in the API.

Once the domain is verified, route all Authentication API traffic through it. Mixing it with api.workos.com causes cookie and issuer mismatches.

Then add the production versions of everything from step 1: the https://app.example.com/callback redirect URI, the sign-in endpoint, the sign-out redirect, and https://app.example.com as an allowed web origin. Production does not accept http or localhost redirect URIs.

Step 6: Staging and preview deployments

Custom domains are only available in production environments. That leaves two common cases.

Deployed staging on a sandbox environment. Without a custom domain, the refresh cookie would come from api.workos.com, which is cross site. Turn on devMode explicitly for that build so the refresh token goes to localStorage instead.

  
<AuthKitProvider
  clientId={import.meta.env.VITE_WORKOS_CLIENT_ID}
  apiHostname={apiHostname}
  devMode={import.meta.env.VITE_WORKOS_DEV_MODE === "true" || undefined}
  onRefreshFailure={({ signIn }) => signIn()}
>
  

Refresh still works the same way, with the same access token lifetime. The only difference from production is where the refresh token lives. If you need staging to behave exactly like production, use a separate production-type environment with its own domain, such as auth-api.staging-example.com.

Preview deployments that use production keys. Preview URLs like my-app-git-feature.vercel.app are a problem for three reasons:

  • vercel.app is a public suffix, so the preview is never on the same site as your Authentication API.
  • Wildcard redirect URIs cannot be used with public suffix domains.
  • Production keys plus devMode means production refresh tokens in localStorage.

The cleanest fix is to serve previews from a subdomain you own, for example *.preview.example.com, if your host supports it. Then:

  • Cookie refresh works without devMode, because previews share example.com.
  • One wildcard redirect URI covers every preview: https://*.preview.example.com/callback.
  • One wildcard allowed web origin covers them too: https://*.preview.example.com.
Settings per environment
Environment WorkOS environment apiHostname devMode Redirect URI
Local Staging Default Automatic http://localhost:5173/callback
Deployed staging Staging Default true https://staging.example.com/callback
Previews Production auth-api.example.com Off https://*.preview.example.com/callback
Production Production auth-api.example.com Off https://app.example.com/callback

Step 7: Handle refresh in real conditions

A few settings decide how the app behaves when the network or the session changes.

  • onRefreshFailure: already set in step 2. Send the user to sign in, and consider saving unsent form data first.
  • onRefresh: runs after each successful refresh. It is a good place for lightweight logging, so you can see refresh activity per user and organization when you debug.
  • refreshBufferInterval: how many seconds before expiry the SDK refreshes. Raise it if your users are often on slow networks.
  • Always call getAccessToken() per request, as in step 3, instead of reading a token you stored earlier.
  
<AuthKitProvider
  clientId={import.meta.env.VITE_WORKOS_CLIENT_ID}
  apiHostname={apiHostname}
  onRefresh={({ user, organizationId }) =>
    console.info("session refreshed", { userId: user.id, organizationId })
  }
  onRefreshFailure={({ signIn }) => signIn()}
>
  

On the server, keep access tokens short. You set their lifetime in the dashboard under Sessions. A short lifetime means a revoked session stops working on your API within minutes, without your API having to call WorkOS on every request.

Production checklist

Before you launch, check each item.

  • The Authentication API custom domain is verified, and it shares a registrable domain with your app.
  • apiHostname points to the Authentication API domain, not the hosted AuthKit domain.
  • devMode is off in production builds.
  • Production redirect URI, sign-in endpoint, sign-out redirect and allowed web origins all use https and your real domain.
  • Your API verifies signature, issuer and expiry on every request.
  • Every data query is scoped to org_id from the token, never to an ID sent by the client.
  • Permission checks happen on the server, not only in the UI.
  • Any returnTo value is checked against your own origin.
  • Preview deployments either use your own subdomain or a separate environment.
  • onRefreshFailure sends users back to sign in.

Wrapping up

The client-only SDKs make sign-in in a React SPA quick. What takes care is the production path: an Authentication API domain on the same site as your app, so the refresh token can live in an httpOnly cookie, and an API that verifies every token instead of trusting the frontend.

From here, you can: