SenseCrypt Docs
Get StartedAdd Login quickstarts

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_secret and is pinned to PKCE.
  • Your SPA's redirect URI on the application's allow-list, e.g. http://localhost:5173/callback in development.

SenseCrypt specifics to know

  • Issuer: https://<your-tenant>.<domain>. Resolve endpoints from {issuer}/.well-known/openid-configuration — never hard-code paths.
  • PKCE S256 is required. plain is rejected. A SPA client must not send a client_secret (SenseCrypt rejects a present-but-blank secret on SPA clients).
  • response_type=code only.
  • id_token is ES256. Verify it against the tenant's jwks_uri. sub is 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-ts
yarn add oidc-client-ts
pnpm add oidc-client-ts

Uncertain (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

src/auth.ts
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 }),
});
.env
VITE_SENSECRYPT_ISSUER=https://<your-tenant>.<domain>
VITE_SENSECRYPT_CLIENT_ID=your_spa_client_id

Trigger the redirect

signinRedirect() generates the code_verifier, code_challenge (S256), and state, stores the verifier in the browser, and navigates to the authorization_endpoint.

src/LoginButton.tsx
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.

src/Callback.tsx
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
  1. Click Sign in with SenseCrypt.
  2. Complete the phone ceremony.
  3. 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_secret rejected / 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_uri mismatch. 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 as iss in the token; the library derives the JWKS from discovery.
  • PKCE invalid_grant. SenseCrypt is S256-only; the verifier from signinRedirect() must survive to the callback (don't clear localStorage in between). The authorization code is single-use.
  • access_denied on 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 right Access-Control-Allow-Origin for 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.

On this page