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
- A KeenAuth environment. Its issuer looks like
https://your-environment.eu.keenauth.app. - A web application in the environment (Create an application) with a client secret (
client_secret_basic). In adevelopmentenvironment, register the redirect URIhttp://localhost/auth/callbackand the post-logout redirect URIhttp://localhost/; a loopback URI is registered without a port and matches any port, such as5173. - An API registered in the environment (Create an API) with the
messages:readscope. Itsidentifier, for examplehttps://api.example.com, is the audience of its access tokens. The Express quickstart 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
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
- 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-clientalso supportsprivate_key_jwt; the example does not show it.
Next steps
- Protect an Express API: the API this app calls.
- Verifying tokens: the verifier under the middleware of your API.