oidc-spa is an OpenID Connect client built for browser-first apps.
It wraps the full Authorization Code + PKCE flow in a high-level API so you can ship secure app auth without stitching together multiple SDKs and ad-hoc glue.
- 🔒 Security-first defaults: in-memory tokens, strict redirect handling, and opt-in defenses like DPoP and token substitution to reduce token exposure risk.
- 🧭 Battle-tested auth UX: token renewal, idle timeout, auto login/logout, multi-tab session sync, and reliable session restore on reload.
- 🧩 Full-stack ready: backend token validation utilities and first-class TanStack Start integration in the same library.
- 🧰 Provider-aware: handles real-world quirks across Keycloak, Entra ID, Auth0, Google, and more.
- ✨ Developer experience: types flow from config into the API, minimal knobs, and easy-to-mock auth for tests.
The Framework-Agnostic Adapter:
import { createOidc, oidcEarlyInit } from "oidc-spa/core"; // ~33 KB min+gzip (See: https://docs.oidc-spa.dev/resources/bundle-size)
import { z } from "zod"; // 59 KB min+gzip, but it's optional.
// Call this only if you don't use oidc-spa's Vite plugin.
oidcEarlyInit({ BASE_URL: "/" });
const oidc = await createOidc({
issuerUri: "https://auth.my-domain.net/realms/myrealm",
//issuerUri: "https://login.microsoftonline.com/...",
//issuerUri: "https://xxx.us.auth0.com/..."
//issuerUri: "https://accounts.google.com/o/oauth2/v2/auth"
clientId: "myclient",
// Optional; you can write a validator by hand, or give up some type-safety, your call.
decodedIdTokenSchema: z.object({
name: z.string(),
picture: z.string().optional(),
email: z.string(),
realm_access: z.object({ roles: z.array(z.string()) })
})
// Yes, really, it's that simple; there are no other parameters to provide.
// The Redirect URI (callback URL) is the root URL of your app (no public/callback.html involved).
});
// In oidc-spa the user is either logged in or they aren't.
// The state will never mutate without a full app reload.
// This makes reasoning about auth much, much easier.
if (!oidc.isUserLoggedIn) {
await oidc.login();
// Never here
return;
}
const { name, realm_access } = oidc.getDecodedIdToken();
console.log(`Hello ${name}`);
const { accessToken } = await oidc.getTokens();
await fetch("https://my-domain.net/api/todos", {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
if (realm_access.roles.includes("realm-admin")) {
// User is an admin
}Higher-level adapters, example with React but we also feature a similar Angular adapter:
Full-stack auth solution with TanStack Start:
import { createServerFn } from "@tanstack/react-start";
import { enforceLogin, oidcFnMiddleware } from "@/oidc";
import fs from "node:fs/promises";
const getTodos = createServerFn({ method: "GET" })
.middleware([oidcFnMiddleware({ assert: "user logged in" })])
.handler(async ({ context: { oidc } }) => {
const userId = oidc.accessTokenClaims.sub;
const json = await fs.readFile(`todos_${userId}.json`, "utf8");
return JSON.parse(json);
});
export const Route = createFileRoute("/todos")({
beforeLoad: enforceLogin,
loader: () => getTodos(),
component: RouteComponent
});
function RouteComponent() {
const todos = Route.useLoaderData();
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.isDone && "✅"} {todo.text}
</li>
))}
</ul>
);
}Project backers, we trust and recommend their services.
Keycloak as a Service — Keycloak community contributors of popular extensions providing free and dedicated Keycloak hosting and enterprise Keycloak support to businesses of all sizes.
