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
JWT Of the Access Token
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:
iframe related issues
By default, applications using oidc-spa will create an iframe pointing to themselves in order to quickly restore the user’s session across reloads and navigations.
However, iframes have a bad reputation. They are sometimes considered an attack vector, and certain system engineers may prefer to forbid their usage entirely rather than applying more nuanced policies.
If your application is served with response headers such as:
Content-Security-Policy: frame-ancestors "none"
or
X-Frame-Options: DENY
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,
Why oidcEarlyInit
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:
Discord Server
Feeling a bit lost? Have a question? A feature request?
Reach out on Discrord!
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.
Is early init mandatory?
No. If not explicitly called, it will be invoked automatically as soon as oidc-spa is evaluated, which, in most setups, happens early enough.
Can it be skipped?
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.
Important caveat for React Router (framework mode)
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 { decodeJwt } from "oidc-spa/tools/decodeJwt";
const decodedAccessToken = decodeJwt(oidc.getTokens().accessToken);
...then your operations team has completely blocked iframe usage, even your SPA is not allowed to iframe itself.
In this scenario, you have two options :
Option 1: Enable the noIframe mode of oidc-spa
oidc-spa provides an option to disable iframe usage:
Note: this will slightly increase the initialization time of your application. Everything will still work as expected, but you won't have the fastest possible startup.
Here you have a comparison of the session resoration process with iframe and without iframe under poor network condition (the auth server is slow to respond).
As you can see, with no iframe there is two consecutive page reload when with ifram everthing is done in the background.
Option 2: Adjust your security policy to allow iframe usage in this context
If possible, request a change in the security policy from your ops team.
Instead of strict policies like:
Content-Security-Policy: frame-ancestors 'none'
X-Frame-Options: DENY
...you can use a more permissive directive like:
Content-Security-Policy: frame-ancestors 'self'
If you'd like to allow iframe usage only in the specific case where the app is iframing itself with typical OIDC query parameters, you can use conditional logic in your reverse proxy:
This configuration allows iframes only when the request query string includes the expected OIDC parameters, typically when the app is restoring a session by iframing itself.
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:
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.
import{createOidc}from"oidc-spa";
Other OIDC Provider
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.
Creating the Client Application
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.
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/
Valid Post-Logout Redirect URIs:
Use the same values as the Valid Redirect URIs.
Web Origins:
https://my-app.com, http://localhost:5173
How Do I Find the issuerUri?
The issuer URI is not always clearly documented—it depends on the provider.
If you are given a Discovery URL like:
Then your issuerUri is:
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!
Scopes and Audience
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 .
Microsoft Entra ID requires you to .
User Session Initialization
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";
src/oidc.ts
import{createReactOidc}from
You can also do this in your React component (although it's maybe not the best approach)
Auto Logout
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 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:
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 { 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 { 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)
}
}, []);
constoidc=awaitcreateOidc({/* ... */});
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.
awaitapi.onboard();// (Example)
}else{
// It was just a page refresh (Ctrl+R)
}
}
"
oidc-spa/react
"
;
exportconst{
/* ... */
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.
awaitapi.onboard();// (Example)
}else{
// It was just a page refresh (Ctrl+R)
}
});
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.
and
http://localhost:5173/dashboard/
Port 5173 is the default for the Vite dev server; adjust as needed for your setup.
import{createOidc}from"oidc-spa";constprOidc=awaitcreateOidc({...});// Function to call when we want to renew the tokenexportfunctionrenewTokens(){constoidc=awaitprOidc;if( !oidc.isUserLoggedIn ){thrownewError("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 renewalprOidc.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 changeunsubscribe();},10_000);});
https://XXX/.well-known/openid-configuration
https://XXX
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>
);
}
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);
});
If your OIDC provider issues a Refresh Token and if this refresh token is a JWT 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";
import{createReactOidc}from"oidc-spa/react";
Displaying a countdown timer before auto logout
Example implementation of a 60 seconds countdown before auto logout.
Why No Client Secret?
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.
Understanding the Two Variants of the Authorization Code Flow
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.
1. Authorization Code Flow (without PKCE)
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.
2. Authorization Code Flow + PKCE (Used by 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.
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 .
Advantages and Trade-offs of Implementing Token Exchange on the Frontend
✅ 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.
How oidc-spa Mitigates the Risks of Token Exposure
oidc-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 XMLHttpRequestmonkey patching – APIs that caries access tokens are frozen to prevent malicious code from overriding it and capturing tokens.
Opinionated Conclusion
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:
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.
(React) You don't need to call enforceLogin() in your loaders.
Handling Initialization Errors
In this mode, must be handled at the <OidcProvider> level.
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, if you don't have autoLogin enabled, 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
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:
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`)
}
);
import{createOidc,typeOidcInitializationError}from"oidc-spa";constoidc=awaitcreateOidc({ // ...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.throwerror;}alert("Sorry, our authentication server is down. Please try again later.");returnnewPromise<never>(()=>{});// Prevent further execution});
importReactfrom"react";importReactDOMfrom"react-dom/client";import{OidcProvider}from"oidc";constrouter=createRouter({routeTree});ReactDOM.createRoot(document.getElementById("root")!).render(<React.StrictMode><OidcProviderErrorFallback={({ initializationError }) => ( <h1style={{ 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> )}><RouterProviderrouter={router}/></OidcProvider></React.StrictMode>);
Warning: this video has been recorded for oidc v5, the API has changed a little bit.
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 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-sparestores 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.
(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.
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";
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.
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.
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.
Here’s how to configure oidc-spa to work with Google:
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 { 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>
</>
);
}
constoidc=awaitcreateOidc(...);
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.
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.
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.
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.
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
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.
This is for setting for integrating oidc-spa with react-router in Framework Mode.
Enabling SPA mode
As of today, to use oidc-spa you need to .
oidc.client.ts
Make sure you create a app/oidc.client.ts file, (instead of app/oidc.ts).
Setting up the entrypoint
Create thoses two files:
Working with loaders
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 primary usecase for a library like oidc-spa is to use it to authenticate against a REST, tRPC, or Websocket API.
Client Side
Let's see a very basic REST API example:
Initialize oidc-spa and expose the oidc instance as a promise:
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
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
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
(include trailing slash; adjust based on your dev server)
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:
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.
Server Side
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 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 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>
);
}
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 };
}
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`)
})();
<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
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.
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 outafter inactivity.
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.
🛍️ Non-Sensitive Apps (E-commerce, Social Media, etc.)
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.
🗑️ Allowing Users to Delete Their Own Accounts
By default, Keycloak does not allow users to delete their accounts.
If you implement a , 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.
Testing the Setup
To test your configuration:
Auth0
This guide explains how to configure Auth0 to obtain the necessary parameters for setting up oidc-spa.
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>
Allowed Logout URLs: Copy paste what you put into Allowed Callback URLs
Allowed Web Origins: The origins of the Callback URLs
Click Save Changes
Creating an API
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.
(Optional) Configuring a Custom Domain
It is highly recommended to set up a custom domain in Auth0 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.
Configuring a Custom Domain in Auth0
Navigate to the .
Click Settings in the left panel.
Open the Custom Domain tab.
Once configured, use your custom domain as the issuerUri:
(Optional) Configuring Auto Logout
If you want users to be automatically logged out after a period of inactivity, follow these steps.
When and Why Enable Auto Logout?
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.
Configuring Session Expiration in Auth0
Navigate to .
Click Settings in the left panel.
Open the Advanced tab.
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:
<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.
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 .
Click Save, and you're done! 🎉
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:
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
Click Assign Role, filter by client, select Delete Account, and assign it.
<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).
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 the web API page for more info.
Click Save.
Configure a custom domain (e.g., auth.my-company.com).
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
const { ... } = createOidc({
// Referred to as "Domain" in Auth0:
issuerUri: "dev-r2h8076n6dns3d4y.us.auth0.com",
clientId: "DzXSmwQS7oSTQGLbafhrPXYLT0mOMyZD",
});
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
<BASE_URL>: Examples: "/" or "/dashboard/".
<DEV_PORT>: Example: 5173 (default for Vite's dev server, adapt to your setup).
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:
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.
Now that you got the idea you can follow up with the specific setup guides for different stacks:
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;