React SPA (PKCE)
Add Sign in with SenseCrypt to a browser-only React single-page app using the OIDC Authorization Code flow with PKCE (no client secret) and an ES256 id_token.
Add Sign in with SenseCrypt to a browser-only React single-page app. A SPA has no backend to hold a secret, so it authenticates with PKCE alone — SenseCrypt requires PKCE with S256, which makes the Authorization Code flow safe for public clients. The face ceremony happens on the user's phone and is invisible to your code.
Public clients can't keep a refresh_token safe. For a pure SPA, prefer short-lived access tokens and re-run the (fast) sign-in when they expire. Only request the offline_access scope if you have a secure place to store the rotating refresh token. If you have any server, the Next.js or Node/Express confidential-client pattern is stronger.
Prerequisites
- A SenseCrypt tenant — its own OIDC issuer at
https://<your-tenant>.<domain>. - An OIDC application registered as SPA / public (Applications → New OIDC application → SPA). A SPA client has no
client_secretand is pinned to PKCE. - Your SPA's redirect URI on the application's allow-list, e.g.
http://localhost:5173/callbackin development.
SenseCrypt specifics to know
- Issuer:
https://<your-tenant>.<domain>. Resolve endpoints from{issuer}/.well-known/openid-configuration— never hard-code paths. - PKCE
S256is required.plainis rejected. A SPA client must not send aclient_secret(SenseCrypt rejects a present-but-blank secret on SPA clients). response_type=codeonly.- id_token is ES256. Verify it against the tenant's
jwks_uri.subis a stable, per-tenant pseudonymous user id.
Install dependencies
Use oidc-client-ts, the maintained successor to oidc-client-js. It implements discovery, PKCE, silent renew, and — importantly — ES256 id_token validation against the JWKS in the browser.
npm install oidc-client-tsyarn add oidc-client-tspnpm add oidc-client-tsUncertain (library specifics): oidc-client-ts defaults to validating the id_token signature when metadata/authority is discovered. Confirm your version keeps signature validation on (it is by default). The SenseCrypt-side requirements — S256, no secret, response_type=code — are library-independent.
Configure the client
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
export const userManager = new UserManager({
// Discovery: oidc-client-ts appends /.well-known/openid-configuration.
authority: import.meta.env.VITE_SENSECRYPT_ISSUER, // https://<tenant>.<domain>
client_id: import.meta.env.VITE_SENSECRYPT_CLIENT_ID,
redirect_uri: `${window.location.origin}/callback`,
response_type: "code", // Authorization Code flow
scope: "openid profile email", // add "offline_access" only if you must
// PKCE + S256 is the library default; SenseCrypt requires S256.
// No client_secret — this is a public client.
userStore: new WebStorageStateStore({ store: window.localStorage }),
});VITE_SENSECRYPT_ISSUER=https://<your-tenant>.<domain>
VITE_SENSECRYPT_CLIENT_ID=your_spa_client_idTrigger the redirect
signinRedirect() generates the code_verifier, code_challenge (S256), and state, stores the verifier in the browser, and navigates to the authorization_endpoint.
import { userManager } from "./auth";
export function LoginButton() {
return (
<button onClick={() => userManager.signinRedirect()}>
Sign in with SenseCrypt
</button>
);
}The user completes the email + QR + face proof on their phone, and SenseCrypt redirects the browser to /callback?code=...&state=....
Handle the callback
On the /callback route, call signinRedirectCallback(). It exchanges the code (sending the stored code_verifier, no secret) and validates the ES256 id_token against the tenant JWKS.
import { useEffect } from "react";
import { userManager } from "./auth";
export function Callback() {
useEffect(() => {
userManager
.signinRedirectCallback()
.then((user) => {
// user.profile has the validated id_token claims (sub, email, …).
console.log("Signed in as", user.profile.sub);
window.location.replace("/"); // clear ?code from the URL
})
.catch((err) => console.error("Sign-in failed", err));
}, []);
return <p>Signing you in…</p>;
}Render <Callback /> at the /callback path in your router, and read the signed-in user anywhere with await userManager.getUser().
The equivalent raw exchange
For reference, this is the token request the library makes for a SPA (note: no client_secret):
curl -X POST {token_endpoint} \
-d grant_type=authorization_code \
-d code={code} \
-d redirect_uri=http://localhost:5173/callback \
-d client_id={client_id} \
-d code_verifier={code_verifier}{
"access_token": "eyJ…",
"id_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}Run and verify
npm run dev # e.g. Vite on http://localhost:5173- Click Sign in with SenseCrypt.
- Complete the phone ceremony.
- You return to
/callback, then home, signed in.
Verify it worked — show the validated claims:
import { useEffect, useState } from "react";
import { userManager } from "./auth";
export function Me() {
const [sub, setSub] = useState<string | null>(null);
useEffect(() => {
userManager.getUser().then((u) => setSub(u?.profile.sub ?? null));
}, []);
return <pre>{sub ? `Signed in: ${sub}` : "Anonymous"}</pre>;
}A stable sub across sign-ins confirms the id_token was validated and the identity is consistent.
Calling your API
To call your own backend API, request an access token with an API audience so aud is your resource server (not the client_id):
scope: "openid profile email",
extraQueryParams: { audience: "https://api.your-app.example" },Send Authorization: Bearer <access_token> to your API, and have the API validate the JWT against the tenant JWKS and check aud. See Tokens & sessions.
Troubleshooting
client_secretrejected /invalid_client. SPA clients must send no secret. Ensure the app is registered as SPA/public and your library isn't injecting a blank secret.redirect_urimismatch. The value must match the allow-list exactly, including scheme, port, and path.- id_token signature errors. Usually a wrong
authority/issuer. It must equal the tenant issuer that appears asissin the token; the library derives the JWKS from discovery. - PKCE
invalid_grant. SenseCrypt isS256-only; the verifier fromsigninRedirect()must survive to the callback (don't clearlocalStoragein between). The authorizationcodeis single-use. access_deniedon the callback. The user's email isn't permitted for this app by the group access gate — an authorization decision, not a bug.- CORS on the token endpoint. For a browser-only client the token exchange is a cross-origin
POST, so the tenant host must return the rightAccess-Control-Allow-Originfor your SPA's origin. How CORS is terminated is deployment-specific (often at a reverse proxy in front of the tenant) — if you see a CORS error, confirm with your SenseCrypt operator that your SPA origin is allowed, or serve the SPA same-origin with the tenant host. (Uncertain: CORS policy is environment-dependent; verify for your tenant.)
Next steps
- Add Login (OIDC) — the protocol-level walkthrough.
- Tokens & sessions — access tokens, API audiences, and why SPAs should avoid long-lived refresh tokens.
- Node/Express — the confidential-client alternative if you add a backend.
Next.js (App Router)
Add Sign in with SenseCrypt to a Next.js App Router app using the OIDC Authorization Code flow with PKCE and an ES256 id_token verified against the tenant JWKS.
Node.js (Express)
Add Sign in with SenseCrypt to a Node.js Express app as a confidential OIDC client using openid-client, the Authorization Code flow with PKCE, and ES256 id_token validation.