Add sign-in to a React Router app

This quickstart adds KeenAuth sign-in to a React Router 8 app in framework mode with the standard OpenID Connect client openid-client. The loaders and actions run on the server, so your app is the OAuth client: it keeps every token in a server-side session, and the browser gets only the HTML and a signed session id cookie. You add the sign-in routes, protect a page, call a protected API with the user's access token, sign out and test the app offline.

For a single-page app without a server, see Add sign-in to a React single-page app.

The app has these routes:

Route What it does
GET / Public home page: a sign-in button, or the user's name and a sign-out button
GET /dashboard Needs a session. Shows the user and the messages that your API returns
POST /login Starts sign-in. Only from a page of this app
GET /auth/callback Checks the response, exchanges the code and starts the session
POST /logout Ends the session here and at KeenAuth. Only from a page of this app

Before you begin

1. Install

npm install --save-exact openid-client@6.8.8

openid-client implements the OpenID Connect relying party: discovery, the authorization code flow with PKCE, the RFC 9207 iss check and the ID token checks. The example pins every package exactly and installs from package-lock.json with npm ci --ignore-scripts, so no package script runs during the install.

2. Configure

The app reads its configuration from environment variables only. Put them in .env, which npm run dev and npm start load with node --env-file=.env, and never commit that file.

Variable Required Meaning
KEENAUTH_ISSUER yes Your environment's issuer, https
KEENAUTH_CLIENT_ID yes The client_id of the web application
KEENAUTH_CLIENT_SECRET yes Its client secret
APP_BASE_URL yes The public URL of the app. The redirect URI is built from it. http only for localhost, 127.0.0.1 or [::1]
SESSION_SECRET yes At least 32 characters; signs the session cookie. Generate one with openssl rand -base64 32
API_URL yes The API the app calls, for example http://localhost:8080
KEENAUTH_API_AUDIENCE yes The identifier of that API in KeenAuth

loadConfig in app/lib/config.server.ts checks these values on the first request. A missing or unusable value stops the request, and no error message contains a value:

  const issuer = url("KEENAUTH_ISSUER", false);
  const appBaseUrl = url("APP_BASE_URL", true).origin;
  const sessionSecret = need("SESSION_SECRET");
  if (sessionSecret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters");

Files that end in .server.ts never reach the browser bundle; React Router stops the build if a page imports one for the browser.

3. Create the OpenID Connect client

app/lib/keenauth.server.ts builds the client once from discovery (<issuer>/.well-known/openid-configuration):

export function keenauth(): Promise<client.Configuration> {
  discovered ??= client
    .discovery(config().issuer, config().clientId, undefined, client.ClientSecretBasic(config().clientSecret), {
      // Also check the ID token signature with your environment's keys (jwks_uri), not only the TLS connection.
      execute: [client.enableNonRepudiationChecks],
    })

By default openid-client trusts an ID token that comes straight from the token endpoint over TLS. enableNonRepudiationChecks also checks its signature with the keys your environment publishes.

4. Keep the session on the server

The session lives in files on the server; the cookie holds only a signed session id:

  storage ??= createFileSessionStorage<SessionData>({
    dir: ".sessions",
    cookie: {
      name: "__keenauth_session",
      httpOnly: true,
      sameSite: "lax",
      // Secure whenever the app is served over https; plain http only for localhost.
      secure: config().redirectUri.startsWith("https:"),
      path: "/",
      maxAge: 8 * 60 * 60,
      secrets: [config().sessionSecret],
    },
  });

A file store serves one server. With several servers, write a session storage with createSessionStorage and a shared store, such as Redis. Do not use the cookie session storage for tokens: two tokens can exceed the 4 KB cookie limit, and the browser would hold them.

5. Start sign-in

The routes are listed in app/routes.ts:

export default [
  index("routes/home.tsx"),
  route("dashboard", "routes/dashboard.tsx"),
  route("login", "routes/login.ts"),
  route("auth/callback", "routes/callback.ts"),
  route("logout", "routes/logout.ts"),
] satisfies RouteConfig;

Sign-in is a form that posts to /login:

        <Form method="post" action="/login" reloadDocument>
          <button type="submit">Sign in</button>
        </Form>

The action makes a new state, nonce and PKCE verifier for each sign-in and keeps them in the session:

export async function action({ request }: Route.ActionArgs) {
  assertSameOrigin(request);
  const session = await getSession(request);
  const transaction = newTransaction();
  session.set("transaction", transaction);
  const url = await authorizationUrl(transaction);
  return redirect(url.href, { headers: { "Set-Cookie": await sessions().commitSession(session) } });
}

The authorization request sends the S256 challenge of the verifier and the identifier of your API as resource (RFC 8707). Without resource, the access token is not meant for your API and the API rejects it.

  return client.buildAuthorizationUrl(await keenauth(), {
    redirect_uri: config().redirectUri,
    // messages:read is a scope of your API; the example API requires it on /api/messages.
    scope: "openid profile email messages:read",
    state: transaction.state,
    nonce: transaction.nonce,
    // PKCE (RFC 7636): S256 of the verifier that only this session knows.
    code_challenge: await client.calculatePKCECodeChallenge(transaction.codeVerifier),
    code_challenge_method: "S256",
    // RFC 8707: an access token for your API.
    resource: config().apiAudience,
  });

Accept the POST only from your own pages

React Router refuses a cross-site POST to the action of a page, but /login and /logout are resource routes (an action without a page), and for those the check is yours. assertSameOrigin accepts the POST only when its Origin header (or, without it, Referer) names APP_BASE_URL:

  const app = new URL(config().redirectUri).origin;
  const source = request.headers.get("Origin") ?? request.headers.get("Referer");

6. Handle the callback

The callback takes the transaction out of the session first, so a state works once:

  const old = await getSession(request);
  const transaction = old.get("transaction");
  // The transaction works once: it leaves the session before anything else happens.
  old.unset("transaction");

authorizationCodeGrant checks state and the iss your environment sends in every authorization response (RFC 9207), exchanges the code with the PKCE verifier and checks the ID token: its signature, iss, aud, exp, iat and the nonce of this sign-in. The redirect URI is built from APP_BASE_URL, never from the Host header:

  const callbackUrl = new URL(config().redirectUri);
  callbackUrl.search = new URL(request.url).search;
  return client.authorizationCodeGrant(await keenauth(), callbackUrl, {
    pkceCodeVerifier: transaction.codeVerifier,
    expectedState: transaction.state,
    expectedNonce: transaction.nonce,
    idTokenExpected: true,
  });

A failed check answers 401 with a fixed error code. After a successful sign-in, the app deletes the old session and keeps the user and the tokens in a new one:

  // A new session id after sign-in prevents session fixation: the old session is deleted, a new one is created.
  await sessions().destroySession(old);
  const session = await sessions().getSession(null);

7. Protect pages and call your API

A loader that needs a user calls requireUser, which redirects to the home page without a session. The example has no refresh token: when the access token expires, the user signs in again.

export async function loader({ request }: Route.LoaderArgs) {
  const { user, accessToken } = await requireUser(request);
  const api = await fetchMessages(accessToken);
  return { user, api };
}

The loader calls your API with the access token as a bearer token. The token stays on the server; the page receives only the user and the messages.

    response = await fetch(`${config().apiUrl}/api/messages`, {
      headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
    });

8. Sign out

Sign-out is a POST from a page of this app. The action ends the local session and sends the browser to your environment's logout endpoint (OpenID Connect RP-Initiated Logout) with id_token_hint, client_id and post_logout_redirect_uri, which returns it to your app:

  const session = await getSession(request);
  const idToken = session.get("idToken");
  const destroyed = await sessions().destroySession(session);
  return redirect((await logoutUrl(idToken)).href, { headers: { "Set-Cookie": destroyed } });

9. Run the app

Start the API from the Express quickstart (or any API that accepts the tokens) and run the app:

npm run dev

Open http://localhost:5173, choose Sign in, sign in at KeenAuth and open the dashboard.

10. Test without a network

The tests need no network and no KeenAuth account. npm test builds the app and runs the build through React Router's request handler. A fake fetch plays your environment (discovery, a key set made for the test run, the token endpoint) and the API.

test("callback: an unknown or reused state is refused before any token request and starts no session", async () => {
  const b = browser();
  await startSignIn(b);
  const wrong = await returnFromKeenAuth(b, { state: "not-the-state" });
npm test
npm run deps    # exact pins, lockfile sources and licences

The tests check that the configuration is validated (and that no error contains a secret); that sign-in sends PKCE S256, state, nonce and resource and is refused from another site or with a GET; that the code is exchanged with the matching verifier and client authentication; that the access token goes to the API in the Authorization header and never to the browser; that an unknown or reused state, a missing or wrong iss, an authorization error and an ID token for another client, with another nonce or signed by another key are refused without a session; that sign-in starts a new session id; that a page without a session redirects to the home page; and that sign-out is refused from another site and goes to the logout endpoint with id_token_hint.

The full sign-in with a real browser needs a KeenAuth environment. It is not part of the offline tests.

Behind a proxy or load balancer

Set APP_BASE_URL to the public URL of the app; the redirect URI and the Origin check use it, never the Host header. If a proxy ends TLS, APP_BASE_URL is still the https URL, so the session cookie stays Secure.

Current limits

Next steps