Only this pageAll pages
Powered by GitBook
1 of 28

v6

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Setup Guides

Loading...

Loading...

Loading...

Providers Configuration

Loading...

Loading...

Loading...

Loading...

Loading...

Resources

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Installation

Before starting be aware that oidc-spa is not suited for Next.js.

If you are using Next the closer alternative is to use NextAuth.js (with the Keycloak adapter if you are using Keycloak). See this guide.

If you're having issues don't hesitate to reach out on Discord!

Add the lib to your dependencies

Editing your App entrypoint

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:

React Router
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
src/main.tsx
import { oidcEarlyInit } from "oidc-spa/entrypoint";

const { shouldLoadApp } = oidcEarlyInit({
    freezeFetch: true,
    freezeXMLHttpRequest: true
});

if (shouldLoadApp) {
    import("./main.lazy");
}
src/index.tsx
import { oidcEarlyInit } from "oidc-spa/entrypoint";

const { shouldLoadApp } = oidcEarlyInit({
    freezeFetch: true,
    freezeXMLHttpRequest: true
});

if (shouldLoadApp) {
    import("./index.lazy");
}

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.

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
});
src/oidc.ts
import { createReactOidc } from "oidc-spa/react";

export const { OidcProvider, useOidc, getOidc } = createReactOidc({
   // ...
   autoLogin: true,
   // postLoginRedirectUrl: "/dashboard"
});

Handling Initialization Errors

In this mode, initialization errors must be handled at the <OidcProvider> level.

TanStack Router

Vite + TypeScript + React + Tanstack Router

TanStack Start support is comming soon. .

The example setup is live here:

Run it locally with:

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";

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)
  }
}
src/oidc.ts
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
Follow progress
https://example-tanstack-router.oidc-spa.dev/

End of third-party cookies

Third-party cookies are now blocked on most browsers, including Chrome, Safari, and Firefox.

Your OIDC provider is considered a third party relative to your application if they do not share a common parent domain.

How Does This Affect You?

Even if your OIDC provider is treated as a third party by the browser, in most cases, this does not impact functionality. oidc-spa works seamlessly even if auth cookies are blocked.

However, if your app is allowed to set cookies on your OIDC provider’s domain, you may see a slight performance improvement during the initial load. This is because we can use silent sign-in via an iframe instead of requiring a full page reload.

The only scenario where blocked cookies significantly degrade the user experience is when both of the following conditions are true:

  • Your OIDC provider does not issue refresh tokens.

  • The access token has a short lifespan.

For example, if the access token expires every 20 seconds, your app will be forced to reload every 18 seconds, which is not ideal.

To avoid this issue, ensure that your OIDC provider shares a common parent domain with your app so that browsers do not treat it as a third party.

When Are Cookies Considered Third-Party?

Third-party cookie restrictions apply when your OIDC provider and your application do not share a parent domain.

Examples:

✅ No third-party cookie issues (Same Parent Domain)

  • App hosted at www.my-company.com, dashboard.my-company.com, or my-company.com/dashboard

  • 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.


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:

Sponsors

Backers of the project

Discord Server

Feeling a bit lost? Have a question? A feature request? Reach out on Discrord!

Mock

For certain use cases, you may want a mock adapter to simulate user authentication without involving an actual authentication server.

This approach is useful when building an app where user authentication is a feature but not a requirement. It also proves beneficial for running tests or in Storybook environments.

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:

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.

    • 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

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:

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!

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 "Create an API" and specify an audience.

  • Microsoft Entra ID requires you to "register an application" and specify a scope.

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:

  • 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.

src/main.tsx
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>
);
src/oidc.ts
import { createReactOidc } from "oidc-spa/react";
import { createMockReactOidc } from "oidc-spa/mock/react";
import { z } from "zod";

const decodedIdTokenSchema = z.object({
    sub: z.string(),
    preferred_username: z.string()
});

const publicUrl = import.meta.env.BASE_URL;

export const { OidcProvider, useOidc, getOidc } =
    !import.meta.env.VITE_OIDC_ISSUER ?
        createMockReactOidc({
            isUserInitiallyLoggedIn: false,
            // This is only so we know where to redirect when 
            // you call `logout({ redirectTo: "home" })`
            homeUrl: import.meta.env.BASE_URL,
            mockedTokens: {
                decodedIdToken: {
                    sub: "123",
                    preferred_username: "john doe"
                } satisfies z.infer<typeof decodedIdTokenSchema>
            }
        }) :
        createReactOidc({
            issuerUri: import.meta.env.VITE_OIDC_ISSUER,
            clientId: import.meta.env.VITE_OIDC_CLIENT_ID,
            homeUrl: import.meta.env.BASE_URL,
            decodedIdTokenSchema
        });
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);

Full-Stack with Node REST API

A full-stack example covering both the backend and frontend

This is a kitchen think example with the following stack:

  • Vite

  • TanStack Router - File Based Routing

  • 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):

User Account Management

In this section we assume you are using Keycloak. If you are using another authentication server you'll have to addapt the queryParameter provided.

When your user is logged in, you can provide a link to redirect to Keycloak so they can manage their account.

There is thee main actions:

  • UPDATE_PASSWORD: Enables the user to change their password.

  • UPDATE_PROFILE: Enable the user to edit teir account information such as first name, last name, email, and any additional user profile attribute that you might have configured on your Keycloak server.

  • delete_account: (In lower case): This enables the user to delete he's account. You must enable it manually on your Keycloak server Admin console. See .

Let's, as an example, how you would implement an update password button:

React Router

The example setup is live here:

Run it locally with:

This is for setting for integrating oidc-spa with react-router in .

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:

Running the example

The example setup is live here:

Run it locally with:

Error Management

Gracefully handle authentication issues

What happens if the OIDC server is down, or if your OIDC server isn't properly configured?

By default, , when there is an error with the OIDC initialization your website will load with the user unauthenticated.

This allows the user to at least access parts of the application that do not require authentication. When the user clicks on the login button (triggering the login() function), a browser alert is displayed, indicating that authentication is currently unavailable, and no further action is taken.

You can customize this behavior. An initializationError object is present on the oidc object if an error occurred.

import { 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>
        </>
    );
}
Keycloak Configuration Guide
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(...);
    
    };
}

Tokens Renewal

You don't need to do it. The token refresh is handled automatically for you, however you can manually trigger a token refresh.

import { createOidc } from "oidc-spa";

const prOidc = await createOidc({ ... });

// Function to call when we want to renew the token
export function renewTokens(){

   const oidc = await prOidc;
   
   if( !oidc.isUserLoggedIn ){
      throw new Error("Logical error");
   }
   
   oidc.renewToken(
      // Optionally you can pass extra params that will be added 
      // to the body of the POST request to the openid-connect/token endpoint.
      // { extraTokenParams: { electedCustomer: "customer123" } }
      // This parameter can also be provided as parameter to the createOidc
      // function. See: https://github.com/keycloakify/oidc-spa/blob/59b8db7db0b47c84e8f383a86677e88e884887cb/src/oidc.ts#L153-L163
   );

}

// Subscribing to token renewal

prOidc.then(oidc => {
    if( !oidc.isUserLoggedIn ){
        return;
    }
    
    const { unsubscribe } = oidc.subscribeToTokensChange(tokens => {
       console.log("Token Renewed", tokens);
    });
    
    setTimeout(() => {
        // Call unsubscribe when you want to stop watching tokens change
        unsubscribe();
    }, 10_000);
});
import { useOidc } from "./oidc";
import { decodeJwt } from "oidc-spa/tools/decodeJwt";

function DisplayAccessToken() {
    const { tokens, renewTokens } = useOidc({ assert: "user logged in" });

    if (tokens === undefined) {
        // NOTE: The tokens object is always initially undefined on first
        // render, the hook will imediately re-render automatically.
        return null;
    }

    return (
        <div>
            <h3>Access Token</h3>
            <pre>{JSON.stringify(decodeJwt(tokens.accessToken), null, 2)}</pre>
            <button onClick={() => renewTokens(
              // Optionally you can pass extra params that will be added 
              // to the body of the POST request to the openid-connect/token endpoint.
              // { extraTokenParams: { electedCustomer: "customer123" } }
              // This parameter can also be provided as parameter to the createOidc
              // function. See: https://github.com/keycloakify/oidc-spa/blob/59b8db7db0b47c84e8f383a86677e88e884887cb/src/oidc.ts#L153-L163      
            )}>Renew Tokens</button>
        </div>
    );
}

Outside of a React Component:

import { createReactOidc } from "oidc-spa/react";

const prOidc = await createReactOidc({ ... });

// Function to call when we want to renew the token
export function renewTokens(){

   const oidc = await prOidc;
   
   if( !oidc.isUserLoggedIn ){
      throw new Error("Logical error");
   }
   
   oidc.renewToken(
      // Optionally you can pass extra params that will be added 
      // to the body of the POST request to the openid-connect/token endpoint.
      // { extraTokenParams: { electedCustomer: "customer123" } }
      // This parameter can also be provided as parameter to the createOidc
      // function. See: https://github.com/keycloakify/oidc-spa/blob/59b8db7db0b47c84e8f383a86677e88e884887cb/src/oidc.ts#L153-L163
   );

}

// Subscribing to token renewal

prOidc.then(oidc => {
    if( !oidc.isUserLoggedIn ){
        return;
    }
    
    const { unsubscribe } = oidc.subscribeToTokensChange(tokens => {
       console.log("Token Renewed", tokens);
    });
    
    setTimeout(() => {
        // Call unsubscribe when you want to stop watching tokens change
        unsubscribe();
    }, 10_000);
});
react-router.config.ts
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;
pages/invoices.tsx
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;
pages/invoices.tsx
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
https://example-react-router.oidc-spa.dev/
Framework Mode
enable SPA mode
autoLogin: true
https://example-react-router-framework.oidc-spa.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

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:

  1. Navigate to Google Cloud Platform Console.

  2. Go to API & Services → Credentials.

  3. Click Create Credentials → OAuth Client ID.

  4. Choose Application Type: Web Application.

  5. 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.

  6. Set the Authorized JavaScript Origins to match the origins of your redirect URIs.

Client Secret

Google's OAuth implementation has a significant flaw: PKCE-based authentication fails unless a client secret is provided.

For public clients, storing secrets is inherently insecure. PKCE (Proof Key for Code Exchange) exists precisely to prevent code interception, and Google supports PKCE. Requiring a client secret in addition to PKCE is unnecessary and misleading.

That said, providing the client secret in your frontend code for this specific case has no security implications. This is purely a poor API design decision on Google's part.

Subtituing the Access Token by the ID Token

Google do not issue JWT Access Tokens and there is no way to configure it so it does.

As a result, if you want to implement an API you'll have to call Google's special endpoint to validate the access token and get user infos. You won't be able to implement the standard approach for validating token described in the 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:

Testing

https://github.com/keycloakify/oidc-spa/blob/a5dbcca428721c4ea4ed1fd36dc07e80fc9d2cb0/examples/tanstack-router/src/oidc.tsx#L28-L38
Warning: this video has been recorded for oidc v5, the API has changed a little bit.
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
Web API

Keycloak

Getting the 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:

    1. Open https://<KC_DOMAIN><KC_RELATIVE_PATH>/admin/master/console.

    2. Log in as an administrator.

    3. Click on the realm selector in the top-left corner.

    4. 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:

  1. Open https://<KC_DOMAIN><KC_RELATIVE_PATH>/admin/master/console.

  2. Log in as an administrator.

  3. Select your realm from the top-left dropdown.

  4. In the left panel, click Clients.

  5. Click Create Client.

  6. Enter a Client ID, for example, myapp, and click Next.

  7. Ensure Client Authentication is off, and Standard Flow is enabled. Click Next.

  8. 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).

  9. Click Save, and you're done! 🎉


Session Lifespan Configuration

One important policy to define is how often users need to re-authenticate when visiting your site.

This configuration does not affect the access token lifetime (default: 5 minutes). It controls how long Keycloak keeps the session active.

🔐 Security-Sensitive Apps (Banking, Admin Panels, etc.)

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:

  1. Disable "Remember Me":

    • Select your realm.

    • Navigate to Realm Settings → Login.

    • Set "Remember Me" to Off.

  2. 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).

  3. Optionally, display a logout countdown before automatic logout:


🛍️ 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):

  1. Enable "Remember Me":

    • Select your realm.

    • Navigate to Realm Settings → Login.

    • Set "Remember Me" to On.

  2. 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.


🗑️ Allowing Users to Delete Their Own Accounts

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:

  1. Navigate to Authentication → Required Actions.

  2. Enable "Delete Account".

  3. Go to Realm Settings → User Registration → Default Roles.

  4. Click Assign Role, filter by client, select Delete Account, and assign it.


Testing the Setup

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

User Impersonation

An example on how to implement user impersonation with Keycloak:

oidc-spa/examples/tanstack-router-file-based at main · keycloakify/oidc-spaGitHub
Keycloak Consulting - Configuration, Deployments and more
Keycloak Consulting Services - Your partner in Keycloak deployment, configuration, and extension development .
Keycloak community contributors of popular providing free and dedicated and enterprise to businesses of all sizes.
Why do I get reCaptcha warnings? | Keycloakify

Auth0

This guide explains how to configure Auth0 to obtain the necessary parameters for setting up oidc-spa.

Creating Your Application

  1. Navigate to .

  2. In the left panel, go to Applications → Applications.

  3. Click Create Application.

  4. Select Single Page Application as the application type.

  5. Navigate to the Settings tab to find the Domain and Client ID.

  6. 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).

  7. Allowed Logout URLs: Copy paste what you put into Allowed Callback URLs

  8. Allowed Web Origins: The origins of the Callback URLs

  9. Click Save Changes

Creating an API

If you need Auth0 to issue a JWT access token for your API, follow these steps:

  1. Navigate to .

  2. In the left panel, go to Applications → APIs.

  3. Click Create API.

  4. 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.

(Optional) Configuring a Custom Domain

It is highly recommended to to ensure Auth0 is not treated as a third-party service by browsers.

Why Is a Custom Domain Important?

By default, Auth0 does not issue a refresh token. If your access token expires and you haven't configured a custom domain, oidc-spa will force reload your app to refresh the token, instead of doing it silently in the background.

Auth0 access tokens have a default validity of 24 hours, so if you don’t modify this setting, you won’t notice page reloads. However, if your app requires shorter expiration times for security reasons, a custom domain is necessary.

Configuring a Custom Domain in Auth0

  1. Navigate to the .

  2. Click Settings in the left panel.

  3. Open the Custom Domain tab.

  4. Configure a custom domain (e.g., auth.my-company.com).

    • See for more details.

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

  1. Navigate to .

  2. Click Settings in the left panel.

  3. Open the Advanced tab.

  4. 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.

  5. Configure Access Token Lifetime:

    1. Go to Applications → APIs.

    2. Select your API (My App - API or the name used earlier).

    3. Open the Settings tab.

    4. 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.

    5. 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:


Testing the Setup

To test your configuration:

Microsoft Entra ID

Formerly Azure Active Directory

Configuring Entra ID to Issue a JWT Access Token

By default, Entra ID issues opaque Access Tokens, which can only be validated by your backend via the Microsoft Graph API.

To enable validation of access tokens in a non-vendor-locked way—such as demonstrated in —you need to configure a custom scope.

Steps to Configure a Custom Scope

  1. Go to .

  2. In the left panel, select "Microsoft Entra ID".

  3. Navigate to "Manage > App Registrations".

  4. Click "New Registration".

  5. Enter "My App - API" as the name, then click Register.

  6. Set Supported Account Type to Accounts in this organization.

  7. In the left menu, go to "Manage > Expose API".

  8. Click "Add a scope".

  9. Configure as follows, then click "Add Scope":

    • Application ID URI: api://my-app-api (then save and continue)

    • Scope name: access_as_user

    • Who can consent: Admins and Users

    • Admin Consent Display Name: "JWT Access Token"

    • Admin Consent Description: "Ensure issuance of a JWT Access Token"

    • User Consent Display Name: "View your basic profile"

    • User Consent Description: "Allows the app to see your basic profile (e.g., name, picture, user name, email address)"

    • State: Enabled

Validating the Token on the Backend

To validate the token on the backend, ensure that the aud claim in the JWT access token matches api://my-app-api. For more details, refer to the .


Registering Your Application

  1. Go to .

  2. In the left panel, select "Microsoft Entra ID".

  3. Navigate to "Manage > App Registrations".

  4. Click "New Registration".

  5. Enter "My App" as the display name (replace with your actual app name).

  6. Set Supported Account Type to Accounts in this organization.

  7. Click Register.

  8. Click "Add a Redirect URI".

  9. Click "Add Platform" > "Single-Page Application".

  10. 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)

  11. Ensure "Access Token" and "ID Token" are checked.

  12. Click Save.

  13. In the left panel, go to "API Permissions".

  14. Click "Add a permission".

  15. Click "APIs My Organization Uses".

  16. Select "My App - API".

  17. Check "access_as_user", then click "Add permission".

  18. In the left panel, click "Overview" and copy:

    • Application (client) ID

    • Directory (tenant) ID

These are required to configure oidc-spa.


Configuring oidc-spa


Testing the Setup

To test your configuration:

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:

  1. The frontend initiates authentication but does not exchange the authorization code directly.

  2. Instead, the backend receives the authorization code and uses a client secret to exchange it for tokens.

  3. The backend stores the access and refresh tokens in a database.

  4. The backend issues an HttpOnly session cookie to the frontend.

  5. The frontend communicates with the backend, which retrieves and attaches access tokens to API requests using the session identifier stored in the cookie.


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:

  1. 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).

  2. 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.

  3. oidc-spa exchanges this code for tokens, completing a cryptographic challenge that eliminates the need for a client secret.

  4. Tokens remain in memory only—oidc-spa does not store them in localStorage, sessionStorage, or a backend database.

  5. When the user refreshes the page or revisits the app, oidc-spa restores the session by querying the OIDC provider in the background.

  6. 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.

  7. 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 .

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 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.

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 Logout
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
Auth0 Dashboard
end of third party cookies
Auth0 Dashboard
the web API page
Auth0 Dashboard
the end of third-party cookie page
Auth0 Dashboard
Auto Logout
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
the Web API section
Microsoft Azure Portal
Web API documentation
Microsoft Azure Portal
Phase Two - Keycloak Hosting | Support | Migration | Customization
extensions
Keycloak hosting
Keycloak support
Cloud IAM - Keycloak Identity and Access Management as a Service
Keycloak as a Service. Use code 'keycloakify5' at checkout for a 5% discount.
Logo
Join the Keycloakify Discord Server!Discord
third-party cookies
this setup

Basic Usage

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
src/oidc.ts
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);
};
src/App.tsx
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>
    );
}
src/components/Header.tsx
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>
    );
}
src/pages/Home.tsx
import { useOidc } from "../oidc";

export default function Page() {
    const { isUserLoggedIn, decodedIdToken } = useOidc();

    return (
        <h1>Welcome {isUserLoggedIn ? decodedIdToken.name : "guest"}!</h1>
    );
}
src/pages/Orders.tsx
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;
src/pages/Account.tsx
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:

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:

  • Keycloak

  • Auth0

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" }
});

Displaying a countdown timer before auto logout

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`)
  }
);
Provider configuration
Setup Guides
Logo
GitHub - InseeFrLab/vite-insee-starter: A template project for SPAsGitHub
GitHub - InseeFrLab/todo-rest-api: Todo app rest API with OIDC and OpenGraphGitHub
Insee SPA Starter
Logo
https://github.com/keycloakify/oidc-spa/tree/main/examples/react-router
oidc-spa/examples/react-router-framework at main · keycloakify/oidc-spaGitHub
Example of oidc.client.ts file
app/entry.client.tsx
app/entry.client.lazy.tsx

Web API

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:

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 (if you have JS backend) and .

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:

Testable example

oidc-spa with Keycloak
src/oidc.ts
import { createReactOidc } from "oidc-spa/react";

export const { 
    OidcProvider, 
    useOidc,
    getOidc
} = createReactOidc(/* ... */);
src/api.ts
import axios from "axios";
import { getOidc } from "./oidc";

type Api = {
    getTodos: () => Promise<{ id: number; title: string; }[]>;
    addTodo: (todo: { title: string; }) => Promise<void>;
};

const axiosInstance = axios.create({ baseURL: import.meta.env.API_URL });

axiosInstance.interceptors.request.use(async config => {

    const oidc= await getOidc();

    if( !oidc.isUserLoggedIn ){
        throw new Error("We made a logic error: The user should be logged in at this point");
    }
    
    const { accessToken } = await oidc.getTokens();
    
    config.headers.Authorization = `Bearer ${accessToken}`;

    return config;

});

export const api: Api = {
    getTodo: ()=> axiosInstance.get("/todo").then(response => response.data),
    addTodo: todo => axiosInstance.post("/todo", todo).then(response => response.data)
};
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`)

})();
tRPC
TanStack Query
src/oidc.ts
import { createOidc } from "oidc-spa";

export const prOidc = createOidc({/* ... */});
src/api.ts
import axios from "axios";
import { prOidc } from "./oidc";

type Api = {
    getTodos: () => Promise<{ id: number; title: string; }[]>;
    addTodo: (todo: { title: string; }) => Promise<void>;
};

const axiosInstance = axios.create({ baseURL: import.meta.env.API_URL });

axiosInstance.interceptors.request.use(async config => {

    const oidc= await prOidc;

    if( !oidc.isUserLoggedIn ){
        throw new Error("We made a logic error: If the user isn't logged in we shouldn't be making request to an API endpoint that requires authentication");
    }
    
    // 99.9% of the times you'll get the token imediately.
    // The 0.1% is after the computer wakes up from sleep.
    const { accessToken } = await oidc.getTokens_next();
    
    config.headers.Authorization = `Bearer ${accessToken}`;

    return config;

});

export const api: Api = {
    getTodo: ()=> axiosInstance.get("/todo").then(response => response.data),
    addTodo: todo => axiosInstance.post("/todo", todo).then(response => response.data)
};
Full-Stack with Node REST API
Logo
Logo
oidc-spa/examples/react-router-framework/app/oidc.client.ts at main · keycloakify/oidc-spaGitHub
oidc-spa/examples/react-router-framework/app/entry.client.tsx at main · keycloakify/oidc-spaGitHub
oidc-spa/examples/react-router-framework/app/entry.client.lazy.tsx at main · keycloakify/oidc-spaGitHub
Example implementation of a 60 seconds countdown before auto logout.
The demo app with a short SSO Session Idle
https://github.com/keycloakify/oidc-spa/blob/main/examples/tanstack-router/src/router/AutoLogoutCountdown.tsx
GitHub - keycloakify/keycloakify-starter at direct_impersonationGitHub
Authorization Code Flow with Proof Key for Code Exchange (PKCE)Auth0 Docs
Logo
Logo
Logo
Logo
Logo
Logo
Logo
Logo
Logo
Logo