
# 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](/docs/quickstarts/react).

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

- A KeenAuth environment. Its issuer looks like `https://your-environment.eu.keenauth.app`.
- A web application in the environment ([Create an application](/docs/api/management/applications/create)) with a client secret (`client_secret_basic`). In a `development` environment, register the redirect URI `http://localhost/auth/callback` and the post-logout redirect URI `http://localhost/`; a loopback URI is registered without a port and matches any port, such as `5173`.
- An API registered in the environment ([Create an API](/docs/api/management/apis/create)) with the `messages:read` scope. Its `identifier`, for example `https://api.example.com`, is the audience of its access tokens. The [Express quickstart](/docs/quickstarts/express) builds an API that accepts these tokens.
- Node.js 22.22 or later, and a React Router app in framework mode (`npx create-react-router@8.5.0`).

## 1. Install

```sh
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:

```ts
  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`):

```ts
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:

```ts
  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`:

```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`:

```tsx
        <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:

```ts
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.

```ts
  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`:

```ts
  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:

```ts
  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:

```ts
  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:

```ts
  // 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.

```ts
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.

```ts
    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:

```ts
  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:

```sh
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.

```ts
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" });
```

```sh
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

- No refresh token and no silent sign-in: the user signs in again when the access token expires.
- The file session store keeps the tokens in plain files under `.sessions/` and does not delete expired files. Protect that directory, or use a shared store that encrypts and expires sessions.
- The client authenticates with a client secret. `openid-client` also supports `private_key_jwt`; the example does not show it.

## Next steps

- [Protect an Express API](/docs/quickstarts/express): the API this app calls.
- [Verifying tokens](/docs/sdk/token-verification): the verifier under the middleware of your API.
