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.
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 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) with
app_typespaandtoken_endpoint_auth_methodnone(a public client has no secret). In adevelopmentenvironment, 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.
- the redirect URI
- 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.18 or later.
1. Install
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:
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
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:
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.
<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:
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:
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:
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:
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
<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:
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.
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");
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: every option, where tokens are kept and the current limits.
- React SDK: embedded sign-in, sign-up and account components.
- Protect an Express API: the API this app calls.