Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
If you're having issues don't hesitate to reach out on Discord!
This is optional but recommended for better performances and security.
First rename your entry point file from main.tsx
(or main.ts
) to main.lazy.tsx
The create a new main.tsx
file:
Comming soon, follow progress.
You can skip this for now. It will be explained in the dedicated setup guide:
First rename your entry point file from main.tsx
(or main.ts
) to main.lazy.tsx
The create a new index.tsx
file:
npm install oidc-spa
yarn add oidc-spa
pnpm add oidc-spa
bun add oidc-spa
mv src/main.tsx src/main.lazy.tsx
mv src/index.tsx src/index.lazy.tsx
import { oidcEarlyInit } from "oidc-spa/entrypoint";
const { shouldLoadApp } = oidcEarlyInit({
freezeFetch: true,
freezeXMLHttpRequest: true
});
if (shouldLoadApp) {
import("./main.lazy");
}
import { oidcEarlyInit } from "oidc-spa/entrypoint";
const { shouldLoadApp } = oidcEarlyInit({
freezeFetch: true,
freezeXMLHttpRequest: true
});
if (shouldLoadApp) {
import("./index.lazy");
}
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.
(React) You don't need to call enforceLogin()
in your loaders.
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 { createReactOidc } from "oidc-spa/react";
export const { OidcProvider, useOidc, getOidc } = createReactOidc({
// ...
autoLogin: true,
// postLoginRedirectUrl: "/dashboard"
});
In this mode, initialization errors must be handled at the <OidcProvider>
level.
Vite + TypeScript + React + Tanstack Router
The example setup is live here:
Run it locally with:
In some cases, you might want to perform some actions when the user login to your app.
It might be clearing some storage values, or calling a specific API endpoint. If this action is costly. You might want to avoid doing it over and over again each time the user refresh the page.
import { createOidc } from "oidc-spa";
const oidc = await createOidc({ /* ... */ });
if (oidc.isUserLoggedIn) {
if( oidc.isNewBrowerSession ){
// This is a new visit of the user on your app
// or the user signed out and signed in again with
// an other identity.
await api.onboard(); // (Example)
}else{
// It was just a page refresh (Ctrl+R)
}
}
import { createReactOidc } from "oidc-spa/react";
export const {
/* ... */
getOidc
} = createReactOidc({ /* ... */ });
getOidc().then(oidc => {
if( oidc.isNewBrowerSession ){
// This is a new visit of the user on your app
// or the user signed out and signed in again with
// an other identity.
await api.onboard(); // (Example)
}else{
// It was just a page refresh (Ctrl+R)
}
});
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(()=> {
if( oidc.isNewBrowerSession ){
// This is a new visit of the user on your app
// or the user signed out and signed in again with
// an other identity.
api.onboard(); // (Example)
}else{
// It was just a page refresh (Ctrl+R)
}
}, []);
npx degit https://github.com/keycloakify/oidc-spa/examples/tanstack-router-file-based oidc-spa-tanstack-router
cd oidc-spa-tanstack-router
cp .env.local.sample .env.local
yarn
yarn dev
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.
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.
Third-party cookie restrictions apply when your OIDC provider and your application do not share a parent domain.
✅ No third-party cookie issues (Same Parent Domain)
App hosted at www.my-company.com
, dashboard.my-company.com
, or my-company.com/dashboard
issuerUri
: https://auth.my-company.com/realms/myrealm
Parent domain: my-company.com
❌ 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.
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:
Backers of the project
Feeling a bit lost? Have a question? A feature request? Reach out on Discrord!
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.
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, however, you still want to access the informations in the access token you can do it with:
If you are using an OIDC provider other than the ones for which we have a specific guide, follow these general instructions to configure your OIDC provider.
Create a Public OpenID Connect client.
OpenID Connect clients may also be referred to as OIDC clients or OAuth clients.
The technical term for a public OIDC client is Authorization Code Flow + PKCE.
If provided with the option, disable client credentials—you do not need to provide a client secret to oidc-spa.
Some providers will ask you to select an application type and choose between Single Page Application (SPA), Web Application (or Web Server App), and Mobile App. Select SPA.
You may need to explicitly provide a Client ID, or it may be generated automatically. This is the clientId
parameter required by oidc-spa.
Valid Redirect URIs: https://my-app.com/ and http://localhost:5173/
The trailing slash (/
) is important.
If your app is hosted on a subpath (e.g., /dashboard
), set:
https://my-app.com/dashboard/ and http://localhost:5173/dashboard/
Port 5173
is the default for the Vite dev server; adjust as needed for your setup.
Valid Post-Logout Redirect URIs: Use the same values as the Valid Redirect URIs.
Web Origins: https://my-app.com, http://localhost:5173
issuerUri
?The issuer URI is not always clearly documented—it depends on the provider.
If you are given a Discovery URL like:
https://XXX/.well-known/openid-configuration
Then your issuerUri
is:
https://XXX
If you suspect a URL might be the issuer URI but are unsure, append /.well-known/openid-configuration
to it and open it in a web browser. If it returns a JSON response, then you have found your issuer URI!
Some OIDC providers require the client (oidc-spa
) to explicitly request a specific scope or audience to issue a JWT access token.
Unfortunately, the configuration varies significantly between providers.
For example:
Auth0 requires you to "Create an API" and specify an audience.
Microsoft Entra ID requires you to "register an application" and specify a scope.
The standard OIDC flow with PKCE is sometimes perceived as less secure than the traditional "backend for frontend" (BFF) flow, where token exchanges happen on the backend and frontend code never has direct access to tokens.
This perception stems mainly from the nature of JavaScript projects, especially SPAs, which often bundle tens of thousands of npm packages from many different authors. If even one of those libraries is compromised, it could potentially steal user tokens unless specific countermeasures are in place. There's also the risk of cross-site scripting (XSS) attacks which, if successful, could lead to the same outcome.
The early init setup recommended in oidc-spa
is designed to mitigate these risks.
By letting a minimal piece of oidc-spa
run before any other JavaScript code, the library ensures that:
The response from the authorization server is removed from the URL and stored in memory (scoped variable), so even if malicious code runs later, it can’t access the tokens.
Critical browser APIs, like window.fetch
, are frozen to prevent malicious code from intercepting or tampering with authenticated requests.
An added (though minor) benefit of the early init + lazy loading setup is improved performance during silent SSO operations in iframes. In the iframe, only a minimal part of the app is evaluated—just the part that needs to forward the auth response to the parent window. Since JS files in SPAs are typically hashed and cached, the benefit isn't about download time but about reducing JavaScript evaluation time. That said, the gain is usually marginal—just a few extra milliseconds in most cases.
No. If not explicitly called, it will be invoked automatically as soon as oidc-spa
is evaluated, which, in most setups, happens early enough.
Yes, if:
The application is free of XSS vulnerabilities.
The dependencies can be fully trusted.
Performance at initialization time is not critical.
In such cases, skipping the setup is reasonable.
If using React Router in framework mode, then the early init setup must be used. React Router performs early redirects that can interfere with oidc-spa
's initialization under certain circumstances.
To avoid issues, oidc-spa
must run first.
If the project is using Vite or Create React App without a higher-level framework, this caveat does not apply.
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>
);
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
});
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
});
import { decodeJwt } from "oidc-spa/tools/decodeJwt";
const decodedAccessToken = decodeJwt(oidc.getTokens().accessToken);
import { decodeJwt } from "oidc-spa/tools/decodeJwt";
const { oidcTokens } = useOidc();
const decodedAccessToken = decodeJwt(oidcTokens.accessToken);
A full-stack example covering both the backend and frontend
This is a kitchen think example with the following stack:
Vite
A Todos Rest API implemented with Node and Hono
The app is live here:
The frontend (Vite project):
The backend (Node Todos App REST API):
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 .
Let's, as an example, how you would implement an update password button:
The example setup is live here:
Run it locally with:
This is for setting for integrating oidc-spa with react-router in .
As of today, to use oidc-spa you need to .
Make sure you create a app/oidc.client.ts
file, (instead of app/oidc.ts
).
Create thoses two files:
If your whole app requires user to be authenticated () you can skip this section.
The default approach when you want to enforce that the user be logged in when accesing a given route is to wrap the component into withLoginEnforced()
, example:
This approach is framework agnostic and always works however, you might want to use the loaders to doload the data, for that you would use enforceLogin()
istead of withLoginEnforced
:
The example setup is live here:
Run it locally with:
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 { 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>
</>
);
}
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 { 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>
);
}
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(...);
};
}
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);
});
import type { Config } from "@react-router/dev/config";
export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: false
} satisfies Config;
import { useState, useEffect } from "react";
import { withLoginEnforced, fetchWithAuth } from "../oidc.client";
const Invoices = withLoginEnforced(
() => {
const [invoices, setInvoices] = useState<Invoice[] | undefined>(undefined);
useEffect(() => {
fetchWithAuth("/api/invoices")
.then(r => r.json())
.then(setInvoices);
}, []);
if (invoices === undefined) {
return <div>Loading invoices...</div>;
}
return (
<div>
{invoices.map(invoice => (
<div key={invoice.id}>{invoice.amount}</div>
))}
</div>
);
},
{
onRedirecting: () => <div>Redirecting to login...</div>
}
);
export default Invoices;
import { enforceLogin, fetchWithAuth } from "../oidc.client";
import type { Route } from "./+types/invoices";
import { useLoaderData } from "react-router";
export async function clientLoader(params: Route.ClientLoaderArgs) {
await enforceLogin(params);
// If we are here, the user is logged in.
const invoices = await fetchWithAuth("/api/invoices").then(r => r.json());
return invoices;
}
export function HydrateFallback() {
return <div>Loading invoices...</div>;
}
export default function Invoices() {
const invoices = useLoaderData<typeof clientLoader>();
return (
<div>
{invoices.map(invoice => (
<div key={invoice.id}>{invoice.amount}</div>
))}
</div>
);
}
npx degit https://github.com/keycloakify/oidc-spa/examples/react-router-framework oidc-spa-react-router
cd oidc-spa-react-router
cp .env.local.sample .env.local
yarn
yarn dev
npx degit https://github.com/keycloakify/oidc-spa/examples/react-router oidc-spa-react-router
cd oidc-spa-react-router
cp .env.local.sample .env.local
yarn
yarn dev
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:
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.
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 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
});
npx degit https://github.com/keycloakify/oidc-spa/examples/tanstack-router-file-based oidc-spa-tanstack-router
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
issuerUri
and clientId
oidc-spa
requires two parameters to connect to your Keycloak instance: issuerUri
and clientId
.
const { ... } = createOidc({
issuerUri: "...",
clientId: "...",
// ...
});
issuerUri
In Keycloak, the OIDC issuer URI follows this format:
https://<KC_DOMAIN><KC_RELATIVE_PATH>/realms/<REALM_NAME>
<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! 🎉
One important policy to define is how often users need to re-authenticate when visiting your site.
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:
For apps where users should remain logged in for weeks or months (e.g., YouTube-style behavior):
Enable "Remember Me":
Select your realm.
Navigate to Realm Settings → Login.
Set "Remember Me" to On.
Configure session timeout:
Users without "Remember Me" will need to log in every 2 weeks:
Set Session idle timeout: 14 days
.
Set Session max idle timeout: 14 days
.
Users who checked "Remember Me" should stay logged in for 1 year:
Set Session idle timeout (Remember Me): 365 days
.
Set Session max idle timeout (Remember Me): 365 days
.
By default, Keycloak does not allow users to delete their accounts.
If you implement a delete account button, users will see an "Action not permitted" error.
Enabling Account Deletion:
Navigate to Authentication → Required Actions.
Enable "Delete Account".
Go to Realm Settings → User Registration → Default Roles.
Click Assign Role, filter by client, select Delete Account, and assign it.
To test your configuration:
npx degit https://github.com/keycloakify/oidc-spa/examples/tanstack-router-file-based oidc-spa-tanstack-router
cd oidc-spa-tanstack-router
cp .env.local.sample .env.local
# Edit the .env.local file to reflect your configuration
yarn
yarn dev
An example on how to implement user impersonation with Keycloak:
This guide explains how to configure Auth0 to obtain the necessary parameters for setting up oidc-spa
.
Navigate to .
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 .
<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
If you need Auth0 to issue a JWT access token for your API, follow these steps:
Navigate to .
In the left panel, go to Applications → APIs.
Click Create API.
Configure the API:
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 for more info.
Click Save.
It is highly recommended to to ensure Auth0 is not treated as a third-party service by browsers.
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.
Navigate to the .
Click Settings in the left panel.
Open the Custom Domain tab.
Configure a custom domain (e.g., auth.my-company.com
).
See for more details.
Once configured, use your custom domain as the issuerUri
:
If you want users to be automatically logged out after a period of inactivity, follow these steps.
For security-critical applications like banking or admin dasboards users should:
Log in on every visit.
Be logged out after inactivity.
This prevents unauthorized access if a user steps away from their device.
For apps like social media or e-comerce shop on the other hand it's best not to enable auto logout.
Navigate to .
Click Settings in the left panel.
Open the Advanced tab.
Configure Session Expiration:
Idle Session Lifetime: 5 minutes
(300 seconds) – logs out inactive users.
Maximum Session Lifetime: 14 days
(20160 minutes) – ensures active users stay logged in.
Configure Access Token Lifetime:
Go to Applications → APIs.
Select your API (My App - API
or the name used earlier).
Open the Settings tab.
Under Access Token Settings:
Maximum Access Token Lifetime: 4 minutes
(240 seconds) – should be shorter than the Idle Session Lifetime.
Implicit/Hybrid Flow Access Token Lifetime: 4 minutes
– required to save settings, even if unused.
Click Save.
Since Auth0 does not issue refresh tokens (or issues non-JWT ones), inform oidc-spa
of your settings:
You can enhance user experience by displaying a countdown warning before logout:
To test your configuration:
Formerly Azure Active Directory
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 —you need to configure a custom scope.
Go to .
In the left panel, select "Microsoft Entra ID".
Navigate to "Manage > App Registrations".
Click "New Registration".
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
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 .
Go to .
In the left panel, select "Microsoft Entra ID".
Navigate to "Manage > App Registrations".
Click "New Registration".
Enter "My App" as the display name (replace with your actual app name).
Set Supported Account Type to Accounts in this organization.
Click Register.
Click "Add a Redirect URI".
Click "Add Platform" > "Single-Page Application".
Set Redirect URIs:
Production: https://my-app.com/
(include trailing slash; adjust if hosted under a subpath, e.g., https://my-app.com/dashboard/
)
Local Development: http://localhost:5173/
(include trailing slash; adjust based on your dev server)
Ensure "Access Token" and "ID Token" are checked.
Click Save.
In the left panel, go to "API Permissions".
Click "Add a permission".
Click "APIs My Organization Uses".
Select "My App - API".
Check "access_as_user", then click "Add permission".
In the left panel, click "Overview" and copy:
Application (client) ID
Directory (tenant) ID
These are required to configure oidc-spa
.
oidc-spa
To test your configuration:
Why Doesn't oidc-spa Require a Client Secret?
You might be wondering why oidc-spa
doesn’t require a client secret and how it securely authenticates users without a backend handling the token exchange with the OIDC provider.
The key lies in the difference between Authorization Code Flow, which requires a client secret, and Authorization Code Flow with PKCE, which does not.
OIDC defines two common implementations of the Authorization Code Flow:
Authorization Code Flow: Requires a backend to handle the token exchange, as a client secret is needed to obtain tokens securely.
Authorization Code Flow + PKCE: Introduces an additional verification step, removing the need for a client secret, allowing secure token exchange directly from the client application.
This flow is typically implemented as follows:
The frontend initiates authentication but does not exchange the authorization code directly.
Instead, the backend receives the authorization code and uses a client secret to exchange it for tokens.
The backend stores the access and refresh tokens in a database.
The backend issues an HttpOnly session cookie to the frontend.
The frontend communicates with the backend, which retrieves and attaches access tokens to API requests using the session identifier stored in the cookie.
oidc-spa
)The standard Authorization Code Flow alone is insufficient for securely exchanging tokens directly from the frontend, as it requires a client secret. Since anything embedded in frontend code is not truly secret, a different approach is needed.
This is where PKCE (Proof Key for Code Exchange) comes in. Here’s how it works with oidc-spa
:
When the user needs to log in—either by clicking a "Login" button or by navigating to a protected part of the app—oidc-spa
redirects them to the OIDC provider's login page (e.g., Keycloak, Auth0).
After successful authentication, the OIDC provider establishes a session, sets an HttpOnly
session cookie in the browser, and redirects the user back to the app with an authorization code.
oidc-spa
exchanges this code for tokens, completing a cryptographic challenge that eliminates the need for a client secret.
Tokens remain in memory only—oidc-spa
does not store them in localStorage, sessionStorage, or a backend database.
When the user refreshes the page or revisits the app, oidc-spa
restores the session by querying the OIDC provider in the background.
The OIDC provider uses the session cookie to determine whether the session is still valid. If it is, fresh tokens are issued without requiring the user to log in again.
If the session has expired, the user is redirected to the login page when accessing a protected area.
Note: You might be concerned about the use of cookies, but here we are referring to session cookies, which do not require GDPR consent and are always enabled in all browsers. A user who disables all cookies would not be able to use any website requiring authentication. Session cookies should not be confused with tracking cookies or .
✅ No persistent token storage – There’s no need to store user tokens in a backend database. The OIDC provider itself acts as the session store, meaning you only need to focus on securely deploying your OIDC server (if self-hosting).
✅ Fewer moving parts – Everything happens between oidc-spa
and the OIDC provider, reducing the chances of misconfiguration.
Even if you're not entirely confident in your setup, as long as it works, you’ve implemented it correctly.
However, in this mode, tokens are exposed to the JavaScript client code, unlike when the token exchange is performed on the backend. This introduces a potential risk: XSS attacks, where malicious code running on your website could attempt to steal tokens.
oidc-spa
Mitigates the Risks of Token Exposureoidc-spa
implements several security measures to minimize the risk of token theft, even in the event of an XSS attack (malicious JavaScript running in your frontend).
Note: This is a work in progress—some of these measures are not yet fully implemented, but they are actively being developed.
To fully benefit from these protections, ensure oidc-spa
is the first JavaScript code that runs on your website by following .
Security Measures:
No persistent token storage – Tokens are never stored in localStorage
or sessionStorage
. Instead, they are kept in scoped variables that are inaccessible to the global scope.
Preventing fetch
and XMLHttpRequest
monkey patching – APIs that caries access tokens are frozen to prevent malicious code from overriding it and capturing tokens.
(WIP) Securing silent sign-in responses – Even if an attacker intercepts the authorization response from a silent sign-in performed in an iframe, it will be asymmetrically encrypted, making it unusable.
(WIP) Secure transfer of the authorization response after front-channel login – The authorization response, which is temporarily stored in session storage during the redirect process, will be cleared and moved to memory before any other code runs.
PKCE is a widely adopted open standard, supported by all major OIDC providers, and provides strong security guarantees without requiring a backend.
While a backend-based token exchange is theoretically more secure, in practice, it introduces additional attack surfaces and operational complexity. Every extra moving part is a potential point of failure or misconfiguration, and securing a backend against threats like token leakage, improper session management, and server-side vulnerabilities is a non-trivial task.
With the security measures implemented in oidc-spa
, Authorization Code Flow + PKCE is not just the simpler approach—it is arguably the safer one in real-world scenarios. By eliminating the backend entirely, it reduces the risk of misconfiguration and ensures that authentication security is handled directly by the OIDC provider, which is purpose-built for this task.
Read more:
const { ... } = createOidc({
// Referred to as "Domain" in Auth0:
issuerUri: "dev-r2h8076n6dns3d4y.us.auth0.com",
clientId: "DzXSmwQS7oSTQGLbafhrPXYLT0mOMyZD",
});
const { ... } = createOidc({
issuerUri: "dev-r2h8076n6dns3d4y.us.auth0.com",
clientId: "DzXSmwQS7oSTQGLbafhrPXYLT0mOMyZD",
extraQueryParams: {
audience: "https://app.my-company.com/api"
}
});
const { ... } = createOidc({
- issuerUri: "dev-r2h8076n6dns3d4y.us.auth0.com",
+ issuerUri: "auth.my-company.com",
clientId: "DzXSmwQS7oSTQGLbafhrPXYLT0mOMyZD",
extraQueryParams: {
audience: "https://app.my-company.com/api"
}
});
const { ... } = createOidc({
issuerUri: "auth.my-company.com",
clientId: "DzXSmwQS7oSTQGLbafhrPXYLT0mOMyZD",
extraQueryParams: {
audience: "https://app.my-company.com/api"
},
idleSessionLifetimeInSeconds: 300
});
npx degit https://github.com/keycloakify/oidc-spa/examples/tanstack-router-file-based oidc-spa-tanstack-router
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
import { createOidc } from "oidc-spa";
// Directory (tenant) ID:
const directoryId = "XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";
// Application (client) ID:
const clientId = "XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";
// Application ID URI: (Of the API!)
const applicationIdUri_api= "api://my-app-api/access_as_user";
export const prOidc = createOidc({
issuerUri: `https://login.microsoftonline.com/${directoryId}/v2.0`,
clientId,
scopes: ["profile", applicationIdUri_api],
homeUrl: import.meta.env.BASE_URL
});
import { createReactOidc } from "oidc-spa/react";
// Directory (tenant) ID:
const directoryId = "XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";
// Application (client) ID:
const clientId = "XXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";
// Application ID URI: (Of the API!)
const applicationIdUri_api= "api://my-app-api/access_as_user";
export const { OidcProvider, useOidc, getOidc } = createReactOidc({
issuerUri: `https://login.microsoftonline.com/${directoryId}/v2.0`,
clientId,
scopes: ["profile", applicationIdUri_api],
homeUrl: import.meta.env.BASE_URL
});
npx degit https://github.com/keycloakify/oidc-spa/examples/tanstack-router-file-based oidc-spa-tanstack-router
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
Let's get your App authenticated!
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}`);
}
The way you use oidc-spa differs slightly depending on the routing library you’re using (e.g., React Router or TanStack Router). We provide specific setup guides for each, but we recommend starting with the fictional example below to understand how the library works in isolation, without any routing-related distractions.
Note: In this example, some pages can be accessed without requiring the user to be authenticated.
If you're building something like an admin panel or a dashboard where authentication is always required, simply set autoLogin: true
.
src/
├── components/
│ └── Header.tsx
├── pages/
│ ├── Home.tsx
│ ├── Account.tsx
│ ├── Orders.tsx
├── App.tsx
└── oidc.tsx
import { createReactOidc } from "oidc-spa/react";
import { z } from "zod";
export const { OidcProvider, useOidc, getOidc, withLoginEnforced, enforceLogin } =
createReactOidc(async () => ({
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()
})
}));
export const fetchWithAuth: typeof fetch = async (
input,
init
) => {
const oidc = await getOidc();
if (oidc.isUserLoggedIn) {
const { accessToken } = await oidc.getTokens();
(init ??= {}).headers = {
...init.headers,
Authorization: `Bearer ${accessToken}`
};
}
return fetch(input, init);
};
import { Suspense, lazy } from "react";
import Header from "./components/Header";
const HomePage = lazy(() => import("./pages/Home"));
const OrderPage = lazy(() => import("./pages/Orders"));
const AccountPage = lazy(() => import("./pages/Account"));
export default function App() {
const route = useRoute();
return (
<OidcProvider
//fallback={<h1>Checking authentication ⌛️</h1>}
>
<Header />
<main>
<Suspense>
{route === "/home" && <HomePage />}
{route === "/orders" && <OrderPage />}
{route === "/account" && <AccountPage />}
</Suspense>
</main>
</OidcProvider>
);
}
import { useOidc } from "../oidc";
export default function Header() {
const { isUserLoggedIn } = useOidc();
return (
<header>
<nav>
<Link to="/home">Home</Link>
<Link to="/orders">Orders</Link>
</nav>
{isUserLoggedIn ? (
<AuthButtonsLoggedIn />
) : (
<AuthButtonsNotLoggedIn />
)}
</header>
);
}
function AuthButtonsLoggedIn() {
const { decodedIdToken, logout } = useOidc({ assert: "user logged in" });
return (
<div>
<span>Logged in as {decodedIdToken.preferred_username}</span>
<Link to="/account">Account</Link>
<button onClick={() => logout({ redirectTo: "home" })}>
Logout
</button>
</div>
);
}
function AuthButtonsNotLoggedIn() {
const { login } = useOidc({ assert: "user not logged in" });
return (
<div>
<button onClick={() => login()}>Login</button>
<button
onClick={() =>
login({
// Keycloak:
transformUrlBeforeRedirect: url => {
const urlObj = new URL(url);
urlObj.pathname = urlObj.pathname.replace(
/\/auth$/,
"/registrations"
);
return urlObj.href;
}
// Auth0:
// extraQueryParams: { screen_hint: "signup" }
})
}
>
Register
</button>
</div>
);
}
import { useOidc } from "../oidc";
export default function Page() {
const { isUserLoggedIn, decodedIdToken } = useOidc();
return (
<h1>Welcome {isUserLoggedIn ? decodedIdToken.name : "guest"}!</h1>
);
}
import { useEffect, useState } from "react";
import { withLoginEnforced, fetchWithAuth } from "../oidc";
type Order = {
id: number;
name: string;
};
// If this component is mounted and the user is not logged in
// the user will be redirected to the login.
// If your routing library support loader you can use enforceLogin
// instead of withLoginEnforced
const Page = withLoginEnforced(() => {
const [orders, setOrders] = useState<Order[] | undefined>(undefined);
useEffect(() => {
fetchWithAuth("https://api.your-domain.net/orders", {
headers: {
"Content-Type": "application/json"
}
})
.then(response => response.json())
.then(orders => setOrders(orders));
}, []);
if (orders === undefined) {
return <>Loading orders ⌛️</>;
}
return (
<ul>
{orders.map(order => (
<li key={order.id}>{order.name}</li>
))}
</ul>
);
});
export default Page;
import { useOidc, withLoginEnforced } from "../oidc";
import { parseKeycloakIssuerUri } from "oidc-spa/tools/parseKeycloakIssuerUri";
const Page = withLoginEnforced(() => {
const {
goToAuthServer,
backFromAuthServer,
params: { issuerUri, clientId }
} = useOidc({ assert: "user logged in" });
const keycloak = parseKeycloakIssuerUri(issuerUri);
if (keycloak === undefined) {
throw new Error(
"We expect Keycloak to be the OIDC provider of this App"
);
}
return (
<div>
<h1>Account</h1>
<p>
<a
href={keycloak.getAccountUrl({
clientId,
backToAppFromAccountUrl: location.href,
locale: "en"
})}
>
Go to Keycloak Account Management Page
</a>
</p>
<p>
<button
onClick={() =>
goToAuthServer({
extraQueryParams: {
kc_action: "CHANGE_PASSWORD"
}
})
}
>
Change My Password
</button>
{backFromAuthServer?.extraQueryParams.kc_action ===
"CHANGE_PASSWORD" && (
<span>
{backFromAuthServer.result.kc_action_status ===
"success"
? "Password Updated!"
: "Password unchanged"}
</span>
)}
</p>
<p>
<button
onClick={() =>
goToAuthServer({
extraQueryParams: {
kc_action: "UPDATE_PROFILE"
}
})
}
>
Update My Profile Information
</button>
{backFromAuthServer?.extraQueryParams.kc_action ===
"UPDATE_PROFILE" && (
<span>
{backFromAuthServer.result.kc_action_status ===
"success"
? "Profile Updated!"
: "Profile unchanged"}
</span>
)}
</p>
<p>
<button
onClick={() =>
goToAuthServer({
extraQueryParams: {
kc_action: "delete_account"
}
})
}
>
Delete My Account
</button>
</p>
</div>
);
});
export default Page;
Now that you got the idea you can follow up with the specific setup guides for different stacks:
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)
Important to understand: This is a policy that is enforced on the identity server. Not in the application code!
In OIDC provider, it is usually referred to as Idle Session Lifetime, these values define how long an inactive session should be kept in the records of the server.
Guide on how to configure it:
you don't need to configure anything at the app level. Otherwise you need to explicitly set the idleSessionLifetimeInSeconds
so it matches with how you have configured your server.
import { createOidc } from "oidc-spa";
const oidc = await createOidc({
// ...
// ‼️ WARNING ‼️ Read carfully what's above.
// Use idleSessionLifetimeInSeconds if and only if you are using an auth server
// that do not let you configure this policy! (e.g. if you're using Keycloak don't use this param)
idleSessionLifetimeInSeconds: 300 // 5 minutes
//autoLogoutParams: { redirectTo: "current page" } // Default
//autoLogoutParams: { redirectTo: "home" }
//autoLogoutParams: { redirectTo: "specific url", url: "/a-page" }
});
import { createReactOidc } from "oidc-spa/react";
export const {
OidcProvider,
useOidc
} = createReactOidc({
// ...
__unsafe_ssoSessionIdleSeconds: 300 // 5 minuts
//autoLogoutParams: { redirectTo: "current page" } // Default
//autoLogoutParams: { redirectTo: "home" }
//autoLogoutParams: { redirectTo: "specific url", url: "/a-page" }
});
const { unsubscribeFromAutoLogoutCountdown } = oidc.subscribeToAutoLogoutCountdown(
({ secondsLeft }) => {
if( secondsLeft === undefined ){
console.log("Countdown reset, the user moved");
return;
}
if( secondsLeft > 60 ){
return;
}
console.log(`${secondsLeft} before auto logout`)
}
);
The primary usecase for a library like oidc-spa is to use it to authenticate against a REST, tRPC, or Websocket API.
Let's see a very basic REST API example:
Initialize oidc-spa and expose the oidc instance as a promise:
Create a REST API Client that adds the OIDC Access Token as Autorization header to every HTTP request:
Create a REST API Client that adds the OIDC Access Token as Autorization header to every HTTP request:
Using your REST API client in your REACT components:
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:
Then you can enforce that some endpoints of your API requires the user to be authenticated, in this example we use Hono:
import { createReactOidc } from "oidc-spa/react";
export const {
OidcProvider,
useOidc,
getOidc
} = createReactOidc(/* ... */);
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)
};
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>
);
}
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 };
}
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`)
})();
import { createOidc } from "oidc-spa";
export const prOidc = createOidc({/* ... */});
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)
};