
# Add sign-in to a React single-page app

This quickstart adds KeenAuth sign-in to a React single-page app built with Vite, using the `@keenauth/js` browser SDK. The app signs users in with a redirect, calls a protected API with a DPoP-bound access token and signs users out. You also test it offline. The SDK is described in [JavaScript browser SDK](/docs/sdk/js).

In a single-page app, the tokens live in the browser. The SDK keeps them only in memory and binds them to a non-extractable key with DPoP, so a stolen token cannot be used without that key. If your app has a server, the [Next.js quickstart](/docs/quickstarts/nextjs) keeps every token on the server instead (backend for frontend), which is the stronger choice.

The app has these paths:

| Path | What it does |
|---|---|
| `/` | A sign-in button, or the user, a button that calls your API and a sign-out button |
| `/callback` | The redirect URI. The SDK finishes the sign-in here and the app returns to the page the user started from |

## Before you begin

- A KeenAuth environment. Its issuer looks like `https://your-environment.eu.keenauth.app`.
- A single-page application in the environment ([Create an application](/docs/api/management/applications/create)) with `app_type` `spa` and `token_endpoint_auth_method` `none` (a public client has no secret). In a `development` environment, register:
  - the redirect URI `http://localhost/callback` (a loopback redirect URI is registered without a port and matches any port);
  - the post-logout redirect URI `http://localhost/`;
  - the allowed web origin `http://localhost:5173`, so that the browser may call the token endpoint.
- 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.18 or later.

## 1. Install

```sh
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install @keenauth/js
```

The SDK is not published yet. Until it is, the example links to the SDK source in its `package.json`. The example pins `vite@8.3.4`, `@vitejs/plugin-react@6.1.2`, `react@19.3.1` and `react-dom@19.3.1`.

## 2. Configure

The app reads its configuration from environment variables only. Put them in `.env.local`, which Vite loads, and never commit that file. Vite builds every `VITE_` variable into the JavaScript that the browser downloads, so put no secret there. This app has none.

| Variable | Required | Meaning |
|---|---|---|
| `VITE_KEENAUTH_ISSUER` | yes | Your environment's issuer |
| `VITE_KEENAUTH_CLIENT_ID` | yes | The `client_id` of the single-page application |
| `VITE_KEENAUTH_API_AUDIENCE` | yes | The identifier of your API in KeenAuth |
| `VITE_API_URL` | no | Base URL of your API. Empty means the app's own origin |
| `API_PROXY_TARGET` | no | Development only: where the dev server forwards `/api`; default `http://localhost:8080` |

`loadConfig` reads and checks these values. It stops the app with an error naming the variable when one is missing:

```ts
export function loadConfig(env: Readonly<Record<string, unknown>>): AppConfig {
  const need = (name: string): string => {
    const v = env[name];
    if (typeof v !== "string" || v.trim() === "") throw new Error(`${name} is required (see .env.example)`);
    return v.trim();
  };
```

## 3. Create the SDK client

```ts
export function createAuth(config: AppConfig, origin: string, inject: Injectables = {}): KeenAuth {
  return new KeenAuth({
    issuer: config.issuer,
    clientId: config.clientId,
    redirectUri: `${origin}${CALLBACK_PATH}`,
    postLogoutRedirectUri: `${origin}/`,
    // The access token is for this API: its aud is the API identifier (RFC 8707).
    resource: config.apiAudience,
    // messages:read is a scope of that API; the example API requires it on /api/messages.
    scope: "openid profile email offline_access messages:read",
    ...inject,
  });
}
```

`resource` is the identifier of your API (RFC 8707). The SDK sends it on the authorization request, the code exchange and every refresh, and the access token's audience is your API. Without it, the access token is not meant for your API and the API rejects it. The SDK checks every option when the client is created; an invalid issuer or `resource` throws a `KeenAuthError` with code `config_invalid`.

Create one client for the page and pass it to a provider:

```tsx
const config = loadConfig(import.meta.env);
const auth = createAuth(config, window.location.origin);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <AuthProvider auth={auth}>
      <App apiUrl={config.apiUrl} />
    </AuthProvider>
  </StrictMode>,
);
```

## 4. Sign in and handle the callback

The sign-in button starts the redirect sign-in: authorization code flow with PKCE (S256), `state` and `nonce`. `appState` is the path to return to.

```tsx
        <button onClick={() => auth.signInRedirect({ appState: window.location.pathname })}>Sign in</button>
```

When KeenAuth redirects back to `/callback`, the provider finishes the sign-in. The SDK checks `state`, `iss` and the ID token, and exchanges the code with a DPoP proof. An authorization response can be used only once, and React's `StrictMode` runs effects twice in development, so `completeSignIn` remembers the first result:

```ts
export function completeSignIn(auth: KeenAuth, href: string): Promise<string | null> {
  const url = new URL(href);
  if (url.pathname !== CALLBACK_PATH || !(url.searchParams.has("code") || url.searchParams.has("error"))) {
    return Promise.resolve(null);
  }
  let done = completions.get(auth);
  if (!done) {
    done = auth.handleRedirectCallback(href).then((r) => safeReturnPath(r.appState));
    completions.set(auth, done);
  }
  return done;
}
```

The provider then removes `code` and `state` from the address bar. Only a path inside your app is used as the return path, so a crafted `appState` cannot send the user to another site:

```ts
export function safeReturnPath(path: string | undefined): string {
  return path !== undefined && /^\/(?![/\\])/.test(path) ? path : "/";
}
```

Serve `index.html` for `/callback` in production too (the Vite dev server does this already).

## 5. Call your API

`authorizedFetch` sends `Authorization: DPoP <token>` with a fresh proof bound to the token, and refreshes the token when it is about to expire:

```ts
export async function fetchMessages(auth: KeenAuth, apiUrl: string): Promise<unknown> {
  const res = await auth.authorizedFetch(`${apiUrl}/api/messages`, { headers: { Accept: "application/json" } });
  if (!res.ok) throw new Error(`The API answered ${res.status}`);
  return res.json();
}
```

In development, the Vite dev server forwards `/api` to your API, so the browser calls it on the app's own origin and the API needs no CORS:

```ts
      proxy: { "/api": env.API_PROXY_TARGET || "http://localhost:8080" },
```

The API checks the DPoP proof against the URL that the browser called. When you run the Express quickstart's API behind this proxy, set its `PUBLIC_BASE_URL` to `http://localhost:5173`. In production, serve the API on the same origin or enable CORS on it for your app's origin, and set `VITE_API_URL`.

## 6. Sign out

```tsx
      <button onClick={() => auth.signOut()}>Sign out</button>
```

`signOut` drops the tokens, revokes the refresh token and sends the browser to your environment's logout endpoint, which returns it to `/`.

## 7. Run the app

Start the API from the Express quickstart with `PUBLIC_BASE_URL=http://localhost:5173` and `KEENAUTH_AUDIENCE` set to your API's identifier, then run the app:

```sh
cp .env.example .env.local   # then fill it in
npm run dev
```

Open `http://localhost:5173`, choose **Sign in**, sign in at KeenAuth and choose **Call the API**.

The SDK keeps tokens only in memory. After a page reload the user is signed out in the app and signs in again; the KeenAuth session usually makes that a redirect without a prompt.

## 8. Test without a network

The tests need no network and no KeenAuth account. A fake `fetch` plays the authorization server and the API, and the ID token is signed with a key made for the test. Node runs the TypeScript tests directly.

```ts
  assert.deepEqual(await fetchMessages(s.auth, ""), { messages: ["hello"] });
  const call = s.seen.find((c) => c.url === `${ORIGIN}/api/messages`)!;
  assert.equal(call.headers.get("Authorization"), "DPoP access-token-1");
```

```sh
npm test
npm run typecheck
npm run deps    # pinned versions, lockfile sources and licences
npm run build
```

The tests check that the configuration is validated, that the SDK refuses an invalid issuer or `resource`, that sign-in sends PKCE S256 and your API as `resource`, that the callback is finished once and sends `resource` with the code, that a forged `state` is refused before any token request, that only a path inside the app is used after sign-in, that no token is written to storage and that the API call carries the DPoP-bound token and its proof.

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

## Next steps

- [JavaScript browser SDK](/docs/sdk/js): every option, where tokens are kept and the current limits.
- [React SDK](/docs/sdk/react): embedded sign-in, sign-up and account components.
- [Protect an Express API](/docs/quickstarts/express): the API this app calls.
