Before getting started, you need to get a hold of the few parameters required to connect to your OIDC provider.
Find instruction on how to configure your OIDC provider on the following documentation page:
import { createOidc } from "oidc-spa";
import { z } from "zod";
const oidc = await createOidc({
issuerUri: "https://auth.your-domain.net/realms/myrealm",
clientId: "myclient",
/**
* Vite: `homeUrl: import.meta.env.BASE_URL`
* CRA: `homeUrl: process.env.PUBLIC_URL`
* Other: `homeUrl: "/"` (Usually, can be something like "/dashboard")
*/
homeUrl: import.meta.env.BASE_URL,
//scopes: ["profile", "email", "api://my-app/access_as_user"],
extraQueryParams: () => ({
ui_locales: "en" // Keycloak login/register page language
//audience: "https://my-app.my-company.com/api"
}),
decodedIdTokenSchema: z.object({
preferred_username: z.string(),
name: z.string()
//email: z.string().email().optional()
})
});
if (!oidc.isUserLoggedIn) {
// The user is not logged in.
// We can call login() to redirect the user to the login/register page.
// This return a promise that never resolve.
oidc.login({
/**
* If you are calling login() in the callback of a click event
* set this to false.
*/
doesCurrentHrefRequiresAuth: false
/**
* Optionally, you can add some extra parameter
* to be added on the login url.
* (Can also be a parameter of createOidc `extraQueryParams: ()=> ({ ui_locales: "fr" })`)
*/
//extraQueryParams: { kc_idp_hint: "google", ui_locales: "fr" }
/**
* You can allso set where to redirect the user after
* successful login
*/
// redirectUrl: "/dashboard"
/**
* Keycloak: You can also send the users directly to the register page
* see: https://github.com/keycloakify/oidc-spa/blob/14a3777601c50fa69d1221495d77668e97443119/examples/tanstack-router-file-based/src/components/Header.tsx#L54-L66
*/
});
} else {
// The user is logged in.
const {
// The accessToken is what you'll use as a Bearer token to
// authenticate to your APIs
accessToken
} = await oidc.getTokens_next();
fetch("https://api.your-domain.net/orders", {
headers: {
Authorization: `Bearer ${accessToken}`
}
})
.then(response => response.json())
.then(orders => console.log(orders));
// To call when the user click on logout.
// You can also redirect to a custom url with
// { redirectTo: "specific url", url: "/bye" }
oidc.logout({ redirectTo: "home" });
const decodedIdToken = oidc.getDecodedIdToken();
console.log(`Hello ${decodedIdToken.preferred_username}`);
}
Create a REST API Client that adds the OIDC Access Token as Autorization header to every HTTP request:
src/api.ts
import axios from "axios";
import { prOidc } from "./oidc";
type Api = {
getTodos: () => Promise<{ id: number; title: string; }[]>;
addTodo: (todo: { title: string; }) => Promise<void>;
};
const axiosInstance = axios.create({ baseURL: import.meta.env.API_URL });
axiosInstance.interceptors.request.use(async config => {
const oidc= await prOidc;
if( !oidc.isUserLoggedIn ){
throw new Error("We made a logic error: If the user isn't logged in we shouldn't be making request to an API endpoint that requires authentication");
}
// 99.9% of the times you'll get the token imediately.
// The 0.1% is after the computer wakes up from sleep.
const { accessToken } = await oidc.getTokens_next();
config.headers.Authorization = `Bearer ${accessToken}`;
return config;
});
export const api: Api = {
getTodo: ()=> axiosInstance.get("/todo").then(response => response.data),
addTodo: todo => axiosInstance.post("/todo", todo).then(response => response.data)
};
Initialize the React adapter of oidc-spa and expose the prOidc object, a promise of the vanilla OIDC API:
Create a REST API Client that adds the OIDC Access Token as Autorization header to every HTTP request:
src/api.ts
import axios from "axios";
import { getOidc } from "oidc";
type Api = {
getTodos: () => Promise<{ id: number; title: string; }[]>;
addTodo: (todo: { title: string; }) => Promise<void>;
};
const axiosInstance = axios.create({ baseURL: import.meta.env.API_URL });
axiosInstance.interceptors.request.use(async config => {
const oidc= await getOidc();
if( !oidc.isUserLoggedIn ){
throw new Error("We made a logic error: The user should be logged in at this point");
}
const { accessToken } = await oidc.getTokens();
config.headers.Authorization = `Bearer ${accessToken}`;
return config;
});
export const api: Api = {
getTodo: ()=> axiosInstance.get("/todo").then(response => response.data),
addTodo: todo => axiosInstance.post("/todo", todo).then(response => response.data)
};
Using your REST API client in your REACT components:
import { api } from "../api";
type Todo= {
id: number;
title: string;
};
function UserTodos(){
const [todos, setTodos] = useState<Todo[] | undefined>(undefined);
useEffect(
()=> {
api.getTodos().then(todos => setTodos(todos));
},
[]
);
if(todos === undefined){
return <>Loading your todos items ⌛️</>
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
This example is purposefully very basic to minimize noise but in your App you might want to consider using solutions like tRPC (if you have JS backend) and TanStack Query.
Backend
If you're implementing a JavaScript Backend (Node/Deno/webworker) oidc-spa also exposes an utility to help you validate and decode the access token that your client sends in the authorization header.
Granted, this is fully optional feel free to use anything else.
Let's assume we have a Node.js REST API build with Express or Hono.
You can create an oidc file as such:
src/oidc.ts
import { createOidcBackend } from "oidc-spa/backend";
import { z } from "zod";
import { HTTPException } from "hono/http-exception";
const zDecodedAccessToken = z.object({
sub: z.string(),
aud: z.union([z.string(), z.array(z.string())]),
realm_access: z.object({
roles: z.array(z.string())
})
// Some other info you might want to read from the accessToken, example:
// preferred_username: z.string()
});
export type DecodedAccessToken = z.infer<typeof zDecodedAccessToken>;
export async function createDecodeAccessToken(params: {
issuerUri: string;
audience: string
}) {
const { issuerUri, audience } = params;
const { verifyAndDecodeAccessToken } = await createOidcBackend({
issuerUri,
decodedAccessTokenSchema: zDecodedAccessToken
});
function decodeAccessToken(params: {
authorizationHeaderValue: string | undefined;
requiredRole?: string;
}): DecodedAccessToken {
const { authorizationHeaderValue, requiredRole } = params;
if (authorizationHeaderValue === undefined) {
throw new HTTPException(401);
}
const result = verifyAndDecodeAccessToken({
accessToken: authorizationHeaderValue.replace(/^Bearer /, "")
});
if (!result.isValid) {
switch (result.errorCase) {
case "does not respect schema":
throw new Error(`The access token does not respect the schema ${result.errorMessage}`);
case "invalid signature":
case "expired":
throw new HTTPException(401);
}
}
const { decodedAccessToken } = result;
if (requiredRole !== undefined && !decodedAccessToken.realm_access.roles.includes(requiredRole)) {
throw new HTTPException(401);
}
{
const { aud } = decodedAccessToken;
const aud_array = typeof aud === "string" ? [aud] : aud;
if (!aud_array.includes(audience)) {
throw new HTTPException(401);
}
}
return decodedAccessToken;
}
return { decodeAccessToken };
}
Then you can enforce that some endpoints of your API requires the user to be authenticated, in this example we use Hono:
import { z, createRoute, OpenAPIHono } from "@hono/zod-openapi";
import { serve } from "@hono/node-server"
import { HTTPException } from "hono/http-exception";
import { getUserTodoStore } from "./todo";
import { createDecodeAccessToken } from "./oidc";
(async function main() {
const { decodeAccessToken } = await createDecodeAccessToken({
// Here example with Keycloak but it work the same with
// any provider as long as the access token is a JWT.
issuerUri: "https://auth.my-company.com/realms/myrealm",
audience: "account" // default audience in Keycloak
});
const app = new OpenAPIHono();
{
const route = createRoute({
method: 'get',
path: '/todos',
responses: {/* ... */}
});
app.openapi(route, async c => {
const decodedAccessToken = decodeAccessToken({
authorizationHeaderValue: c.req.header("Authorization")
});
const todos = await getUserTodoStore(decodedAccessToken.sub).getAll();
return c.json(todos);
});
}
const port = parseInt(process.env.PORT);
serve({
fetch: app.fetch,
port
})
console.log(`\nServer running. OpenAPI documentation available at http://localhost:${port}/doc`)
})();
Automatically logging out your user after a set period of inactivity on your app (they dont move the mouse or press any key on the keyboard for a while)
Configuring auto logout policy
Important to understand: This is a policy that is enforced on the identity server. Not on in the application code!
In OIDC provider it is usually refered to as Idle Session Lifetime, this values define for how long an inactive session should be kept in the records of the server.
you don't need to configure anything at the app level. Otherwise you need to explicitely set the idleSessionLifetimeInSeconds so it matches how you have configured your server.
Example implementation of a 60 seconds countdown before autologout.
Auto Login
Enforce authentication everywhere in your app.
If your application requires users to be authenticated at all times—such as dashboards or admin panels—you can configure oidc-spa to automatically redirect unauthenticated users to the login page. This ensures that no part of your app is accessible without authentication.
This is similar to wrapping your root component with withLoginEnforced(), but with a key difference: oidc-spa assumes the user will never be unauthenticated. This means you do not need to:
Check isUserLoggedIn, as it will always be true.
(React) Use the assertion useOidc({ assert: "user logged in" }), since the user is guaranteed to be logged in.
(React) Use withLoginEnforced, it is not exposed in this mode since it is always enforced.
import { createOidc, type OidcInitializationError } from "oidc-spa";
const oidc = await createOidc({
// ...
autoLogin: true,
// postLoginRedirectUrl: "/dashboard"
})
.catch((error: OidcInitializationError) => {
// Handle potential initialization errors
// In this mode, falling back to an unauthenticated state is not an option.
if (!error.isAuthServerLikelyDown) {
// This indicates a misconfiguration in your OIDC server.
throw error;
}
alert("Sorry, our authentication server is down. Please try again later.");
return new Promise<never>(() => {}); // Prevent further execution
});
import React from "react";
import ReactDOM from "react-dom/client";
import { OidcProvider } from "oidc";
const router = createRouter({ routeTree });
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<OidcProvider
ErrorFallback={({ initializationError }) => (
<h1 style={{ color: "red" }}>
{initializationError.isAuthServerLikelyDown ? (
<>Sorry, our authentication server is currently down. Please try again later.</>
) : (
// Debugging: Use initializationError.message for details.
// This is an issue on your end and should not be shown to users.
<>Unexpected authentication error.</>
)}
</h1>
)}
>
<RouterProvider router={router} />
</OidcProvider>
</React.StrictMode>
);
Error Management
Gracefully handle authentication issues
What happens if the OIDC server is down, or if your OIDC server isn't properly configured?
By default, , when there is an error with the OIDC initialization your website will load with the user unauthenticated.
This allows the user to at least access parts of the application that do not require authentication. When the user clicks on the login button (triggering the login() function), a browser alert is displayed, indicating that authentication is currently unavailable, and no further action is taken.
You can customize this behavior. An initializationError object is present on the oidc object if an error occurred.
import { createOidc } from "oidc-spa";
const oidc = await createOidc(...);
if( !oidc.isUserLoggedIn ){
// If the used is logged in we had no initialization error.
return;
}
if( oidc.initializationError ){
// This help you discriminate configuration errors
// and error due to the server being temporarely down.
console.log(oidc.initializationError.isAuthServerLikelyDown);
const handleLoginClick = ()=> {
if( oidc.initializationError ){
alert(`Can't login now, try again later ${oidc.initializationError.message}`);
return;
}
oidc.login(...);
};
}
import { useOidc } from "oidc";
import { useEffect } from "react";
function LoginButton() {
const { isUserLoggedIn, login, logout, initializationError } = useOidc();
useEffect(() => {
if (initializationError) {
// This help you discriminate configuration errors
// and error due to the server being temporarely down.
console.log(initializationError.isAuthServerLikelyDown);
// This is a debug message that tells you what's wrong
// with your configuration and how to fix it.
// (this is not something you want to display to the user)
console.log(initializationError.message);
}
}, []);
if (isUserLoggedIn) {
return <button onClick={()=> logout({ redirectTo: "home" })}>Logout</button>;
}
return (
<button onClick={() => {
if (initializationError) {
alert("Can't login now, try again later")
return;
}
login({ ... });
}}>
Login
</button>
);
}
Mock
For certain use cases, you may want a mock adapter to simulate user authentication without involving an actual authentication server.
This approach is useful when building an app where user authentication is a feature but not a requirement. It also proves beneficial for running tests or in Storybook environments.
Warning: this video has been recorded for oidc v5, the API has changed a little bit.
import { createOidc } from "oidc-spa";
import { createMockOidc } from "oidc-spa/mock";
import { z } from "zod";
const decodedIdTokenSchema = z.object({
sub: z.string(),
preferred_username: z.string()
});
const oidc = !import.meta.env.VITE_OIDC_ISSUER
? await createMockOidc({
isUserInitiallyLoggedIn: false,
// This is only so we know where to redirect when
// you call `logout({ redirectTo: "home" })`
homeUrl: import.meata.env.BASE_URL,
mockedTokens: {
decodedIdToken: {
sub: "123",
preferred_username: "john doe"
} satisfies z.infer<typeof decodedIdTokenSchema>
}
})
: await createOidc({
issuerUri: import.meta.env.VITE_OIDC_ISSUER,
clientId: import.meta.env.VITE_OIDC_CLIENT_ID,
homeUrl: import.meta.env.BASE_URL,
decodedIdTokenSchema
});
src/oidc.ts
import { createReactOidc } from "oidc-spa/react";
import { createMockReactOidc } from "oidc-spa/mock/react";
import { z } from "zod";
const decodedIdTokenSchema = z.object({
sub: z.string(),
preferred_username: z.string()
});
const publicUrl = import.meta.env.BASE_URL;
export const { OidcProvider, useOidc, getOidc } =
!import.meta.env.VITE_OIDC_ISSUER ?
createMockReactOidc({
isUserInitiallyLoggedIn: false,
// This is only so we know where to redirect when
// you call `logout({ redirectTo: "home" })`
homeUrl: import.meta.env.BASE_URL,
mockedTokens: {
decodedIdToken: {
sub: "123",
preferred_username: "john doe"
} satisfies z.infer<typeof decodedIdTokenSchema>
}
}) :
createReactOidc({
issuerUri: import.meta.env.VITE_OIDC_ISSUER,
clientId: import.meta.env.VITE_OIDC_CLIENT_ID,
homeUrl: import.meta.env.BASE_URL,
decodedIdTokenSchema
});
User Account Management
In this section we assume you are using Keycloak. If you are using another authentication server you'll have to addapt the queryParameter provided.
When your user is logged in, you can provide a link to redirect to Keycloak so they can manage their account.
There is thee main actions:
UPDATE_PASSWORD: Enables the user to change their password.
UPDATE_PROFILE: Enable the user to edit teir account information such as first name, last name, email, and any additional user profile attribute that you might have configured on your Keycloak server.
delete_account: (In lower case): This enables the user to delete he's account. You must enable it manually on your Keycloak server Admin console. See Keycloak Configuration Guide.
Let's, as an example, how you would implement an update password button:
import { createOidc } from "oidc-spa";
import { parseKeycloakIssuerUri } from "oidc-spa/tools/parseKeycloakIssuerUri";
const oidc = await createOidc({ ... });
if( oidc.isUserLoggedIn ){
// Function to invoke when the user click on your "change my password" button.
const updatePassword = ()=>
oidc.goToAuthServer({
extraQueryParams: {
kc_action: "UPDATE_PASSWORD"
}
});
// NOTE: This is optional, it enables you to display a feedback message
// when the user is redirected back to your application after completing
// or canceling the action.
if(
oidc.backFromAuthServer?.extraQueryParams.kc_action === "UPDATE_PASSWORD"
){
switch(oidc.backFromAuthServer.result.kc_action_status){
case "canceled":
alert("You password was not updated");
break;
case "success":
alert("Your password has been updated successfuly");
break;
}
}
}
// Url for redirecting users to the keycloak account console.
const keycloakAccountUrl = parseKeycloakIssuerUri(oidc.params.issuerUri)
.getAccountUrl({
clientId: params.clientId,
backToAppFromAccountUrl: `${location.href}${import.meta.env.BASE_URL}`
});
import { parseKeycloakIssuerUri } from "oidc-spa/tools/parseKeycloakIssuerUri";
function ProtectedPage() {
// Here we can safely assume that the user is logged in.
const { goToAuthServer, backFromAuthServer, params } = useOidc({ assert: "user logged in" });
return (
<>
<button
onClick={() =>
goToAuthServer({
extraQueryParams: { kc_action: "UPDATE_PASSWORD" }
})
}
>
Change password
</button>
{/*
Optionally you can display a feedback message to the user when they
are redirected back to the app after completing or canceling the
action.
*/}
{backFromAuthServer?.extraQueryParams.kc_action === "UPDATE_PASSWORD" && (
<p>
{(()=>{
switch(backFromAuthServer.result.kc_action_status){
case "success":
return "Password successfully updated";
case "cancelled":
return "Password unchanged";
}
})()}
</p>
)}
<a
href={parseKeycloakIssuerUri(params.issuerUri)!.getAccountUrl({
clientId,
backToAppFromAccountUrl: location.href
})}
>
My Account
</a>
</>
);
}
User Session Initialization
In some cases, you might want to perform some operation to initialize the user's session. This could involve calling a special API endpoint or clearing some cached values in the local storage.
What you don't want, however, is to run this every time the user refreshes the page.
To help you determine if the session should be initialized, you can leverage the isNewBrowserSession property that is available when the user is logged in.
import { createOidc } from "oidc-spa";
const oidc = await createOidc({ /* ... */ });
if (oidc.isUserLoggedIn && oidc.isNewBrowserSession) {
// This is a new visit of this user.
}
if(oidc.isUserLoggedIn && oidc.isNewBrowserSession && oidc.backFromAuthServer ){
// This is a new visit AND a new OIDC session has just beeing created.
// on the OIDC server.
}
src/oidc.ts
import { createReactOidc } from "oidc-spa/react";
export const {
/* ... */
getOidc
} = createReactOidc({ /* ... */ });
getOidc().then(oidc => {
if (oidc.isUserLoggedIn && oidc.isNewBrowserSession) {
// This is a new visit of this user.
}
if(oidc.isUserLoggedIn && oidc.isNewBrowserSession && oidc.backFromAuthServer ){
// This is a new visit AND a new OIDC session has just beeing created.
// on the OIDC server.
}
});
You can also do this in your React component (although it's maybe not the best approach)
import { useOidc } from "./oidc";
import { useEffect } from "react";
function MyComponent(){
const { isUserLoggedIn, isNewBrowserSession, backFromAuthServer } = useOidc();
useEffect(()=> {
// Warning! In dev mode, when React Strict Mode is enabled
// this will be called twice!
if (isUserLoggedIn && isNewBrowserSession) {
// This is a new visit of this user.
}
if(isUserLoggedIn && isNewBrowserSession && backFromAuthServer ){
// This is a new visit AND a new OIDC session has just beeing created.
// on the OIDC server.
}
}, []);
Tokens Renewal
You don't need to do it. The token refresh is handled automatically for you, however you can manually trigger a token refresh.
import { createOidc } from "oidc-spa";
const prOidc = await createOidc({ ... });
// Function to call when we want to renew the token
export function renewTokens(){
const oidc = await prOidc;
if( !oidc.isUserLoggedIn ){
throw new Error("Logical error");
}
oidc.renewToken(
// Optionally you can pass extra params that will be added
// to the body of the POST request to the openid-connect/token endpoint.
// { extraTokenParams: { electedCustomer: "customer123" } }
// This parameter can also be provided as parameter to the createOidc
// function. See: https://github.com/keycloakify/oidc-spa/blob/59b8db7db0b47c84e8f383a86677e88e884887cb/src/oidc.ts#L153-L163
);
}
// Subscribing to token renewal
prOidc.then(oidc => {
if( !oidc.isUserLoggedIn ){
return;
}
const { unsubscribe } = oidc.subscribeToTokensChange(tokens => {
console.log("Token Renewed", tokens);
});
setTimeout(() => {
// Call unsubscribe when you want to stop watching tokens change
unsubscribe();
}, 10_000);
});
import { useOidc } from "./oidc";
import { decodeJwt } from "oidc-spa/tools/decodeJwt";
function DisplayAccessToken() {
const { tokens, renewTokens } = useOidc({ assert: "user logged in" });
if (tokens === undefined) {
// NOTE: The tokens object is always initially undefined on first
// render, the hook will imediately re-render automatically.
return null;
}
return (
<div>
<h3>Access Token</h3>
<pre>{JSON.stringify(decodeJwt(tokens.accessToken), null, 2)}</pre>
<button onClick={() => renewTokens(
// Optionally you can pass extra params that will be added
// to the body of the POST request to the openid-connect/token endpoint.
// { extraTokenParams: { electedCustomer: "customer123" } }
// This parameter can also be provided as parameter to the createOidc
// function. See: https://github.com/keycloakify/oidc-spa/blob/59b8db7db0b47c84e8f383a86677e88e884887cb/src/oidc.ts#L153-L163
)}>Renew Tokens</button>
</div>
);
}
Outside of a React Component:
import { createReactOidc } from "oidc-spa/react";
const prOidc = await createReactOidc({ ... });
// Function to call when we want to renew the token
export function renewTokens(){
const oidc = await prOidc;
if( !oidc.isUserLoggedIn ){
throw new Error("Logical error");
}
oidc.renewToken(
// Optionally you can pass extra params that will be added
// to the body of the POST request to the openid-connect/token endpoint.
// { extraTokenParams: { electedCustomer: "customer123" } }
// This parameter can also be provided as parameter to the createOidc
// function. See: https://github.com/keycloakify/oidc-spa/blob/59b8db7db0b47c84e8f383a86677e88e884887cb/src/oidc.ts#L153-L163
);
}
// Subscribing to token renewal
prOidc.then(oidc => {
if( !oidc.isUserLoggedIn ){
return;
}
const { unsubscribe } = oidc.subscribeToTokensChange(tokens => {
console.log("Token Renewed", tokens);
});
setTimeout(() => {
// Call unsubscribe when you want to stop watching tokens change
unsubscribe();
}, 10_000);
});
<KC_DOMAIN>: The domain where your Keycloak server is hosted (e.g., auth.my-company.com).
<KC_RELATIVE_PATH>: The subpath under which Keycloak is hosted. In recent versions, this is an empty string (""). In older versions, it was "/auth".
Check your Keycloak server configuration; this parameter is typically set using an environment variable:
Example: -e KC_HTTP_RELATIVE_PATH=/auth
<REALM_NAME>: The name of your realm (e.g., myrealm).
🔹 Important: Always create a dedicated realm for your organization—never use the master realm.
To create a new realm:
Open https://<KC_DOMAIN><KC_RELATIVE_PATH>/admin/master/console.
Log in as an administrator.
Click on the realm selector in the top-left corner.
Click "Create a new Realm", give it a name, and save.
clientId
The clientId is usually something like 'myapp'. Follow these steps to create a suitable client for your SPA:
Open https://<KC_DOMAIN><KC_RELATIVE_PATH>/admin/master/console.
Log in as an administrator.
Select your realm from the top-left dropdown.
In the left panel, click Clients.
Click Create Client.
Enter a Client ID, for example, myapp, and click Next.
Ensure Client Authentication is off, and Standard Flow is enabled. Click Next.
Set two Valid Redirect URIs, ensure both URLs end with /:
https://<APP_DOMAIN><BASE_URL>
http://localhost:<DEV_PORT><BASE_URL>
Parameters:
<APP_DOMAIN>: Examples: https://my-company.com or https://app.my-company.com.
🔹 For beter performances ensure <APP_DOMAIN> and <KC_DOMAIN> share the same root domain (my-company.com). See end of third party cookies.
<BASE_URL>: Examples: "/" or "/dashboard/".
<DEV_PORT>: Example: 5173 (default for Vite's dev server, adapt to your setup).
Click Save, and you're done! 🎉
Session Lifespan Configuration
One important policy to define is how often users need to re-authenticate when visiting your site.
This configuration does not affect the access token lifetime (default: 5 minutes). It controls how long Keycloak keeps the session active.
For security-critical apps, users should log in each visit and be logged out .
Why?
Users accessing sensitive applications should not remain authenticated indefinitely, especially if they step away from their device. The session idle timeout ensures automatic logout after inactivity.
Steps to enforce this policy:
Disable "Remember Me":
Select your realm.
Navigate to Realm Settings → Login.
Set "Remember Me" to Off.
Configure session timeout:
Go to Realm Settings → Sessions.
Set SSO Session idle: 5 minutes (ensures users are logged out after 5 minutes of inactivity).
Set SSO Session max idle: 14 days (ensures users who actively use the app don’t get logged out unnecessarily).
Optionally, display a logout countdown before automatic logout:
In the left panel, go to Applications → Applications.
Click Create Application.
Select Single Page Application as the application type.
Navigate to the Settings tab to find the Domain and Client ID.
Scroll to the Application URIs section. Set two Allowed Callback URLs, ensure both URLs end with /:
https://<APP_DOMAIN><BASE_URL>
http://localhost:<DEV_PORT><BASE_URL>
Parameters:
<APP_DOMAIN>: Examples: https://my-company.com or https://app.my-company.com.
🔹 For beter performances ensure <APP_DOMAIN> and <KC_DOMAIN> share the same root domain (my-company.com). See end of third party cookies.
<BASE_URL>: Examples: "/" or "/dashboard/".
<DEV_PORT>: Example: 5173 (default for Vite's dev server, adapt to your setup).
Allowed Logout URLs: Copy paste what you put into Allowed Callback URLs
Allowed Web Origins: The origins of the Callback URLs
Click Save Changes
const { ... } = createOidc({
// Referred to as "Domain" in Auth0:
issuerUri: "dev-r2h8076n6dns3d4y.us.auth0.com",
clientId: "DzXSmwQS7oSTQGLbafhrPXYLT0mOMyZD",
});
Creating an API
If you need Auth0 to issue a JWT access token for your API, follow these steps:
Identifier: Ideally, use your API's root URL (e.g., https://myapp.my-company.com/api). However, this is just an identifier, so any unique string works. It will be the aud claim of the access tokens issued. See the web API page for more info.
It is highly recommended to to ensure Auth0 is not treated as a third-party service by browsers.
Why Is a Custom Domain Important?
By default, Auth0 does not issue a refresh token. If your access token expires and you haven't configured a custom domain, oidc-spa will force reload your app to refresh the token, instead of doing it silently in the background.
Auth0 access tokens have a default validity of 24 hours, so if you don’t modify this setting, you won’t notice page reloads. However, if your app requires shorter expiration times for security reasons, a custom domain is necessary.
git clone https://github.com/keycloakify/oidc-spa
mv oidc-spa/examples/tanstack-router-file-based oidc-spa-tanstack-router
rm -rf oidc-spa
cd oidc-spa-tanstack-router
cp .env.local.sample .env.local
# Uncomment the Auth0 section and comment out the Keycloak section.
# Update the values with your own.
yarn
yarn dev
Microsoft Entra ID
Formerly Azure Active Directory
Configuring Entra ID to Issue a JWT Access Token
By default, Entra ID issues opaque Access Tokens, which can only be validated by your backend via the Microsoft Graph API.
To enable validation of access tokens in a non-vendor-locked way—such as demonstrated in the Web API section—you need to configure a custom scope.
Enter "My App - API" as the name, then click Register.
Set Supported Account Type to Accounts in this organization.
In the left menu, go to "Manage > Expose API".
Click "Add a scope".
Configure as follows, then click "Add Scope":
Application ID URI: api://my-app-api (then save and continue)
Scope name: access_as_user
Who can consent: Admins and Users
Admin Consent Display Name: "JWT Access Token"
Admin Consent Description: "Ensure issuance of a JWT Access Token"
User Consent Display Name: "View your basic profile"
User Consent Description: "Allows the app to see your basic profile (e.g., name, picture, user name, email address)"
State: Enabled
Validating the Token on the Backend
To validate the token on the backend, ensure that the aud claim in the JWT access token matches api://my-app-api. For more details, refer to the Web API documentation.
git clone https://github.com/keycloakify/oidc-spa
mv oidc-spa/examples/tanstack-router-file-based oidc-spa-tanstack-router
rm -rf oidc-spa
cd oidc-spa-tanstack-router
cp .env.local.sample .env.local
# Uncomment the Microsoft Entra ID section and comment out the Keycloak section.
# Update the values with your own.
yarn
yarn dev
Google OAuth 2.0
Implement "Login with Google"
With oidc-spa, you would typically use an OIDC Provider like Keycloak or Auth0 to centralize authentication and configure Google as an identity provider within Keycloak. This allows users to select "Google" as a login option.
That being said, if you really want to, you can configure oidc-spa directly with Google, as demonstrated in the following video:
Google Cloud Console Configuration
To set up authentication via Google, follow these steps in the Google Cloud Console:
Navigate to Google Cloud Platform Console.
Go to API & Services → Credentials.
Click Create Credentials → OAuth Client ID.
Choose Application Type: Web Application.
Set the Authorized Redirect URIs:
https://my-app.com/ and http://localhost:5173/ (Ensure the trailing slash is included).
If your app is hosted under a subpath (e.g., /dashboard), set:
https://my-app.com/dashboard/
http://localhost:5173/dashboard/
5173 is Vite's default development server port—adjust as needed.
Set the Authorized JavaScript Origins to match the origins of your redirect URIs.
Client Secret
Google's OAuth implementation has a significant flaw: PKCE-based authentication fails unless a client secret is provided.
For public clients, storing secrets is inherently insecure. PKCE (Proof Key for Code Exchange) exists precisely to prevent code interception, and Google supports PKCE. Requiring a client secret in addition to PKCE is unnecessary and misleading.
That said, providing the client secret in your frontend code for this specific case has no security implications. This is purely a poor API design decision on Google's part.
Subtituing the Access Token by the ID Token
Google do not issue JWT Access Tokens and there is no way to configure it so it does.
As a result, if you want to implement an API you'll have to call Google's special endpoint to validate the access token and get user infos.
You won't be able to implement the standard approach for validating token described in the Web API section.
Well there is a way to go around this, and that is to ask oidc-spa to substitute the Acess Token by the ID token.
Be aware that this is a hack, the ID token is not meant to be sent to the API but it works.
Here’s how to configure oidc-spa to work with Google:
import { createOidc } from "oidc-spa";
export const prOidc = createOidc({
issuerUri: "https://accounts.google.com",
clientId: "928024164279-ifjvmsffi64slkk81h3gmoh7p03ev68k.apps.googleusercontent.com",
homeUrl: import.meta.env.BASE_URL,
scope: ["profile", "email",
/*Obtionally more scopes to get more infos in the id token like "https://www.googleapis.com/auth/youtube.readonly", ...*/
],
__unsafe_clientSecret: "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",
__unsafe_useIdTokenAsAccessToken: true
});
import { createReactOidc } from "oidc-spa/react";
export const { OidcProvider, useOidc, getOidc } = createReactOidc({
issuerUri: "https://accounts.google.com",
clientId: "928024164279-ifjvmsffi64slkk81h3gmoh7p03ev68k.apps.googleusercontent.com",
homeUrl: import.meta.env.BASE_URL,
scope: ["profile", "email",
/*Obtionally more scopes to get more info in the id token like "https://www.googleapis.com/auth/youtube.readonly", ...*/
],
__unsafe_clientSecret: "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",
__unsafe_useIdTokenAsAccessToken: true
});
Testing
git clone https://github.com/keycloakify/oidc-spa
mv oidc-spa/examples/tanstack-router-file-based oidc-spa-tanstack-router
rm -rf oidc-spa
cd oidc-spa-tanstack-router
cp .env.local.sample .env.local
# Edit .env.local, uncomment the Google section and comment the Keycloak section
# replace the values by your own.
yarn
yarn dev
Other OIDC Provider
If you are using an OIDC Provider other than the ones for wich we have a specific guide for, here are the general instruction on how to configure your OIDC Provider:
Create a OpenID Connect client with Standard Flow also refered to as Authorization code flow. It's usualy the default setting.
Disable client authentication (public client). We don't want to rely on client secret, it would be instantaneously leaked by your SPA. This means enforcing PKCE.
Valid Redirect URIs: https://my-app.com/ and http://localhost:5173/
The slash at the end is important
If your app is hosted on a sub path like /dashboard you would set https://my-app.com/dashboard/ and http://localhost:5173/dashboard/
5173 is the default port used by Vite dev server, adapt according to your setup.
Valid post logout redirect: Same as the Valid Redirec URIs
Web Origins: https://my-app.com, http://localhost:5173
Resources
End of third-party cookies
Third-party cookies are now blocked on most browsers, including Chrome, Safari, and Firefox.
Your OIDC provider is considered a third party relative to your application if they do not share a common parent domain.
How Does This Affect You?
Even if your OIDC provider is treated as a third party by the browser, in most cases, this does not impact functionality.oidc-spa works seamlessly even if auth cookies are blocked.
However, if your app is allowed to set cookies on your OIDC provider’s domain, you may see a slight performance improvement during the initial load. This is because we can use silent sign-in via an iframe instead of requiring a full page reload.
The only scenario where blocked cookies significantly degrade the user experience is when both of the following conditions are true:
Your OIDC provider does not issue refresh tokens.
The access token has a short lifespan.
For example, if the access token expires every 20 seconds, your app will be forced to reload every 18 seconds, which is not ideal.
To avoid this issue, ensure that your OIDC provider shares a common parent domain with your app so that browsers do not treat it as a third party.
When Are Cookies Considered Third-Party?
Third-party cookie restrictions apply when your OIDC provider and your application do not share a parent domain.
Examples:
✅ No third-party cookie issues (Same Parent Domain)
App hosted at www.my-company.com, dashboard.my-company.com, or my-company.com/dashboard
❌ Cookies blocked as third party (Different Parent Domains)
App hosted at my-company.com
issuerUri:
https://accounts.google.com
https://xxxx.us.auth0.com
https://login.microsoftonline.com/xxx/v2.0
https://hydra.project-name.ory.cloud/
No common parent domain → Third-party cookies will be blocked.
Google reCAPTCHA
While reCAPTCHA is not directly related to oidc-spa, its cookies are set on the login page, outside of your app. Since this is a related concern, you can find more details here:
And why it's not supposed to be read on the client side.
You might be surprised or even frustrated by the fact that oidc-spa only provides the decoded id token but not the decoded access token.
Infact the access token is supposed to be opaque for the client application is to be used only as an authentication key such as a Bearer token for an API.
As per the OIDC standard the access token is not even required to be a JWT!
But worry not, everything that you need is probably in the id token, if there is something missing in your id token that is present in your access token there is an explicit policy on your identity server in place that strips this information out.
Zod is stripping out all the claims that are not specified in the schema. This might have led you to believe that there is less information in the id token than what actually is.
If you're experiencing performance issues—meaning that the createOidc() function takes too long to resolve or that your application remains stuck on the fallback of <OidcProvider /> (e.g., <>Checking authentication ⌛️</>) for several hundred milliseconds—it may be due to your application's architecture. Specifically, if your application performs asynchronous operations before oidc-spa is imported, the silent sign-in process (executed in an iframe) can be delayed.
Solution
You don’t need to restructure your application. Instead, simply add the following two lines to your entry file:
src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { handleOidcCallback } from "oidc-spa/oidc/handleOidcCallback";
handleOidcCallback();
ReactDOM.createRoot(document.getElementById("root")!).render(
//...
);
This ensures that the authentication server's response is processed as early as possible, minimizing delays while keeping the impact on your bundle size minimal. 👍
Discord Server
Feeling a bit lost? Have a question? A feature request?
Reach out on Discrord!