Skip to content
Esc
navigateopen⌘Jpreview
Dashboard
On this page

Multiple frontend domains with separate backends

Set up multiple frontend domains with separate backends using OAuth2 authentication.

Overview

You can use the following guide if you have a single Authorization Service that multiple applications use. In turn, each app has separate frontend and backend instances that serve from different domains. The authentication flow works in the following way:

The User accesses the frontend app

  • The application frontend calls a login endpoint on the backend application.
  • The backend application generates an authorization URL to the Authorization Service and redirects the user to it.
  • The Authorization Service backend redirects the user to the login UI

The User completes the login attempt

  • The Authorization Service backend redirects the user to a callback URL that includes the Authorization Code.

The user accesses the callback URL

  • The Authorization Code and state are sent to the application backend.
  • The backend verifies state, exchanges the Authorization Code, and keeps the OAuth tokens server-side.
  • The backend rotates the application session and sends only an opaque session identifier in a cookie.

The frontend uses an opaque HttpOnly, Secure, appropriately SameSite application-session cookie to access its backend. OAuth access and refresh tokens never enter browser-readable storage.

Multiple Frontend Domains with separate Backends

Before you start

Enable paid features

This feature is only available to paid users. Follow the instructions below to enable it.

This feature is available only with the SuperTokens Managed Service.

Managed Service

  1. Sign in to the SuperTokens dashboard.
  2. Select the managed service option from the service type select component.
  3. Select your core instance from the next elemenet or create a new one.
  4. Open Features sub-page and enable the required ones.

Steps

1. Enable the Unified Login feature

Go to the SuperTokens.com SaaS Dashboard, select the relevant Managed deployment, and open Features. Enable Unified Login. Changes are saved automatically.

2. Create the OAuth2 Clients

For each application, create a separate OAuth2 client. Call the SuperTokens Core API from a trusted administrative environment. Because each application backend performs the code exchange and can protect credentials, these are confidential clients. The examples below register client_secret_basic, which is appropriate for Go oauth2, Authlib, League OAuth2 Client with HttpBasicAuthOptionProvider, Spring Security, and ASP.NET Core. Never expose a client secret to frontend code, logs, URLs, or browser storage.

curl --location --request POST '<CORE_API_ENDPOINT>/recipe/oauth/clients' \
     --header 'api-key: <YOUR_API_KEY>' \
     --header 'Content-Type: application/json; charset=utf-8' \
     --data '
    {
      "clientName": "<YOUR_CLIENT_NAME>",
      "responseTypes": ["code"],
      "grantTypes": ["authorization_code", "refresh_token"],
      "tokenEndpointAuthMethod": "client_secret_basic",
      "audience": ["<YOUR_APPLICATION_RESOURCE_SERVER>"],
      "scope": "offline_access <custom_scope_1> <custom_scope_2>",
      "redirectUris": ["https://<YOUR_APPLICATION_DOMAIN>/oauth/callback"]
    }
'
const BASE_URL = "<CORE_API_ENDPOINT>";
const API_KEY = "<YOUR_API_KEY>";

const url = `${BASE_URL}/recipe/oauth/clients`;
const options = {
  method: "POST",
  headers: {
    "api-key": API_KEY,
    "Content-Type": "application/json; charset=utf-8",
  },
  body: JSON.stringify({
    clientName: "<YOUR_CLIENT_NAME>",
    responseTypes: ["code"],
    grantTypes: ["authorization_code", "refresh_token"],
    tokenEndpointAuthMethod: "client_secret_basic",
    audience: ["<YOUR_APPLICATION_RESOURCE_SERVER>"],
    scope: "offline_access <custom_scope_1> <custom_scope_2>",
    redirectUris: ["https://<YOUR_APPLICATION_DOMAIN>/oauth/callback"],
  }),
};

fetch(url, options)
  .then((response) => response.json())
  .then((json) => console.log(json))
  .catch((err) => console.error(err));

import (
  "fmt"
  "net/http"
  "strings"
  "io"
)

func main() {
  baseUrl := "<CORE_API_ENDPOINT>"
  apiKey := "<YOUR_API_KEY>"
  url := fmt.Sprintf("%s/recipe/oauth/clients", baseUrl)
  payload := `{
    "clientName": "<YOUR_CLIENT_NAME>",
    "responseTypes": ["code"],
    "grantTypes": ["authorization_code", "refresh_token"],
    "tokenEndpointAuthMethod": "client_secret_basic",
    "audience": ["<YOUR_APPLICATION_RESOURCE_SERVER>"],
    "scope": "offline_access <custom_scope_1> <custom_scope_2>",
    "redirectUris": ["https://<YOUR_APPLICATION_DOMAIN>/oauth/callback"]
  }`

  req, _ := http.NewRequest("POST", url, strings.NewReader(payload))

  req.Header.Add("accept", "application/json")
  req.Header.Add("api-key", apiKey)
  req.Header.Add("content-type", "application/json")

  res, _ := http.DefaultClient.Do(req)

  defer res.Body.Close()
  body, _ := io.ReadAll(res.Body)

  fmt.Println(string(body))
}
import requests
from typing import Dict, Any

BASE_URL = "<CORE_API_ENDPOINT>"
API_KEY = "<YOUR_API_KEY>"

url = f"{BASE_URL}/recipe/oauth/clients"

payload: Dict[str, Any] ={
  "clientName": "<YOUR_CLIENT_NAME>",
  "responseTypes": ["code"],
  "grantTypes": ["authorization_code", "refresh_token"],
  "tokenEndpointAuthMethod": "client_secret_basic",
  "audience": ["<YOUR_APPLICATION_RESOURCE_SERVER>"],
  "scope": "offline_access <custom_scope_1> <custom_scope_2>",
  "redirectUris": ["https://<YOUR_APPLICATION_DOMAIN>/oauth/callback"]
}

headers = {
    "api-key": API_KEY,
    "Content-Type": "application/json",
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())

Creates an OAuth2 client Authorization: Set the api-key header to the value of your SuperTokens Core API key.

Request

Body Schema

Name Type Description Required Default Value
clientName string A human-readable name of the client used for identification. Yes -
grantTypes array of GrantType The grant types that the Client uses. Yes -
redirectUris array of string Exact redirect URIs registered for the client. Wildcards are not supported. Yes -
audience array of string Resource-server identifiers allowed in access tokens. No -
scope string String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. Include the offline_access scope to exchange OAuth2 Refresh Tokens for OAuth2 Access Tokens No “”
responseTypes array of ResponseType The types of responses your client expects from the Authorization Server No -
tokenEndpointAuthMethod enum("client_secret_basic", "client_secret_post", "private_key_jwt", "none") The requested client authentication method No client_secret_basic
authorizationCodeGrantAccessTokenLifespan Time Duration OAuth2 Access Token lifespan when using the Authorization Code grant flow. No "1h"
authorizationCodeGrantIdTokenLifespan Time Duration OAuth2 ID Token lifespan when using the Authorization Code grant flow. No "1h"
authorizationCodeGrantRefreshTokenLifespan Time Duration OAuth2 Refresh Token lifespan when using the Authorization Code grant flow. If refreshTokenGrantRefreshTokenLifespan is also set "30d"
refreshTokenGrantRefreshTokenLifespan Time Duration OAuth2 Refresh Token lifespan when using the Refresh Token grant flow. Must match authorizationCodeGrantRefreshTokenLifespan. If authorizationCodeGrantRefreshTokenLifespan is also set "30d"
clientCredentialsGrantAccessTokenLifespan Time Duration OAuth2 Access Token lifespan when using the Client Credentials grant flow. No "1h"
enableRefreshTokenRotation boolean Indicates that the refresh token is a one-time use. Set it to false to disable refresh token rotation. No true

GrantType

  • authorization_code: allows exchanging the Authorization Code for an OAuth2 Access Token.
  • refresh_token: allows exchanging the OAuth2 Refresh Token for an OAuth2 Access Token.
  • client_credentials: allows the client to directly request an OAuth2 Access Token by authenticating itself with the Authorization Server using its own client credentials.

TokenEndpointAuthMethod

  • client_secret_basic: uses the HTTP Basic Authentication scheme to authenticate the client.
  • client_secret_post: uses the HTTP POST Authentication scheme to authenticate the client.
  • private_key_jwt: uses JSON Web Tokens (JWT) to authenticate the client.
  • none: indicates that the process of obtaining an OAuth2 Access Token does not use the client secret. Used for public clients (native apps or mobile apps).

ResponseType

  • code: Indicates that the Client receives an Authorization Code that it exchanges for an OAuth2 Access Token.
  • id_token: Indicates that the Client expects an ID Token.

Time Duration

A string value that signifies time duration in milliseconds, seconds, minutes, or hours: "2000ms", "60s", "30m", "1h".

Example

curl -X POST <CORE_API_ENDPOINT>/recipe/oauth/clients \
  -H "Content-Type: application/json" \
  -H "api-key: <YOUR_API_KEY>" \
  -d '{
      "clientName": "<YOUR_CLIENT_NAME>",
      "responseTypes": ["code"],
      "grantTypes": ["authorization_code", "refresh_token"],
      "tokenEndpointAuthMethod": "client_secret_basic",
      "audience": ["<YOUR_APPLICATION_RESOURCE_SERVER>"],
      "scope": "offline_access <custom_scope_1> <custom_scope_2>",
      "redirectUris": ["https://<YOUR_APPLICATION_DOMAIN>/oauth/callback"]
    }'

Response

200

The client has been successfully created.

Relevant response fields

The response includes the persisted client configuration, including the fields below.

Property Type Description
clientName string The name of the client.
clientId string Unique identifier for the client.
clientSecret string Client secret for a confidential client. Omitted for a public client. Treat it as a credential and keep it on a trusted backend.
redirectUris array of string The URLs used for redirection.
audience array of string Value used to identify for whom a token is issued. The created client can generate access token only for the specified audiences.
scope string A space-separated string of scopes that the client can request.
responseTypes array of string Registered response types.
grantTypes array of string Registered grant types.
tokenEndpointAuthMethod string Token endpoint authentication method.
enableRefreshTokenRotation boolean Whether refresh token rotation is enabled.

Example

{
  "clientName": "<YOUR_CLIENT_NAME>",
  "clientId": "<CLIENT_ID>",
  "clientSecret": "<CLIENT_SECRET>",
  "tokenEndpointAuthMethod": "client_secret_basic",
  "audience": ["<YOUR_APPLICATION_RESOURCE_SERVER>"],
  "redirectUris": ["https://<YOUR_APPLICATION_DOMAIN>/oauth/callback"],
  "scope": "offline_access <custom_scope_1> <custom_scope_2>"
}

3. Set up your Authorization Service backend

In your Authorization Service you need to initialize the OAuth2Provider recipe. The recipe exposes the endpoints needed for enabling the OAuth 2.0 flow.

Update the supertokens.init call to include the OAuth2Provider recipe.

Add the import statement for the recipe and update the recipe list with the new initialization step.

import supertokens from "supertokens-node";
import EmailPassword from "supertokens-node/recipe/emailpassword";
import OAuth2Provider from "supertokens-node/recipe/oauth2provider";

supertokens.init({
  supertokens: {
    connectionURI: "...",
    apiKey: "...",
  },
  appInfo: {
    appName: "...",
    apiDomain: "...",
    websiteDomain: "...",
  },
  recipeList: [EmailPassword.init(), OAuth2Provider.init()],
});
from supertokens_python import init, InputAppInfo, SupertokensConfig
from supertokens_python.recipe import emailpassword, oauth2provider

init(
    app_info=InputAppInfo(
        app_name="...",
        api_domain="...",
        website_domain="...",
    ),
    framework="fastapi",
    supertokens_config=SupertokensConfig(
        connection_uri="...",
        api_key="..."
    ),
    recipe_list=[
        emailpassword.init(),
        oauth2provider.init(),
    ],
)

4. Configure the Authorization Service frontend

UI type

4.1 Initialize the recipe

Add the import statement for the new recipe and update the list of recipes to also include the new initialization.

Update the AuthComponent to include the OAuth2Provider recipe. You need to add a new item in the recipeList array.

Update the AuthView component to include the OAuth2Provider recipe. You need to add a new item in the recipeList array, inside the supertokensUIInit call.

import OAuth2Provider from "supertokens-auth-react/recipe/oauth2provider";
import EmailPassword from "supertokens-auth-react/recipe/emailpassword";
import SuperTokens from "supertokens-auth-react";

SuperTokens.init({
  appInfo: {
    appName: "...",
    apiDomain: "...",
    websiteDomain: "...",
  },
  recipeList: [EmailPassword.init(), OAuth2Provider.init()],
});
import { init as supertokensUIInit } from "supertokens-auth-react";
import supertokensUIOAuth2Provider from "supertokens-auth-react/recipe/oauth2provider";
import { Component, OnDestroy, AfterViewInit, Renderer2, Inject } from "@angular/core";
import { DOCUMENT } from "@angular/common";

@Component({
  selector: "app-auth",
  template: '<div id="supertokensui"></div>',
})
export class AuthComponent implements OnDestroy, AfterViewInit {
  constructor(
    private renderer: Renderer2,
    @Inject(DOCUMENT) private document: Document,
  ) {}

  ngAfterViewInit() {
    this.loadScript("https://cdn.jsdelivr.net/gh/supertokens/prebuiltui@vX.Y.Z/build/static/js/main.test.js");
  }

  ngOnDestroy() {
    // Remove the script when the component is destroyed
    const script = this.document.getElementById("supertokens-script");
    if (script) {
      script.remove();
    }
  }

  private loadScript(src: string) {
    const script = this.renderer.createElement("script");
    script.type = "text/javascript";
    script.src = src;
    script.id = "supertokens-script";
    script.onload = () => {
      supertokensUIInit({
        appInfo: {
          appName: "<YOUR_APP_NAME>",
          apiDomain: "<YOUR_API_DOMAIN>",
          websiteDomain: "<YOUR_WEBSITE_DOMAIN>",
          apiBasePath: "/auth",
          websiteBasePath: "/auth",
        },
        recipeList: [
          // Don't forget to also include the other recipes that you are already using
          supertokensUIOAuth2Provider.init(),
        ],
      });
    };
    this.renderer.appendChild(this.document.body, script);
  }
}
import {init as supertokensUIInit} from "supertokens-auth-react"; import supertokensUIOAuth2Provider from
"supertokens-auth-react/recipe/oauth2provider";
<script lang="ts">
  import { defineComponent, onMounted, onUnmounted } from "vue";
  export default defineComponent({
    setup() {
      const loadScript = (src: string) => {
        const script = document.createElement("script");
        script.type = "text/javascript";
        script.src = src;
        script.id = "supertokens-script";
        script.onload = () => {
          supertokensUIInit({
            appInfo: {
              appName: "<YOUR_APP_NAME>",
              apiDomain: "<YOUR_API_DOMAIN>",
              websiteDomain: "<YOUR_WEBSITE_DOMAIN>",
              apiBasePath: "/auth",
              websiteBasePath: "/auth",
            },
            recipeList: [
              // Don't forget to also include the other recipes that you are already using
              supertokensUIOAuth2Provider.init(),
            ],
          });
        };
        document.body.appendChild(script);
      };

      onMounted(() => {
        loadScript("https://cdn.jsdelivr.net/gh/supertokens/prebuiltui@vX.Y.Z/build/static/js/main.test.js");
      });

      onUnmounted(() => {
        const script = document.getElementById("supertokens-script");
        if (script) {
          script.remove();
        }
      });
    },
  });
</script>

<template>
  <div id="supertokensui" />
</template>

5. Set up session handling in each application

In each of your individual applications you need to set up logic for handling the OAuth 2.0 authentication flow. Use a framework OAuth 2.0/OIDC login middleware rather than implementing the protocol manually. The authorization endpoint is <YOUR_API_DOMAIN>/auth/oauth/auth, and the token endpoint is <YOUR_API_DOMAIN>/auth/oauth/token. Each registered callback must exactly match one of the client’s redirectUris.

A secure implementation must:

  1. Generate a high-entropy state, bind it to the initiating browser session, and verify it exactly before code exchange. Use PKCE as defense in depth where the library supports it.
  2. For OIDC, request openid, generate and verify nonce, and validate the ID token signature, issuer, audience, expiry, and nonce.
  3. Keep OAuth access and refresh tokens in a server-side session store. After callback, rotate the application session identifier to prevent session fixation.
  4. Return only an opaque session identifier in an HttpOnly, Secure, appropriately SameSite cookie. Never return OAuth tokens in browser-readable cookies, JavaScript storage, or URLs.
  5. Preserve the selected tenantId through authorization, callback, and the resulting application session.

With passport-oauth2, state protection and PKCE are opt-in. Configure both. Install server-side Express session middleware before Passport; do not use a client-side cookie session store. This example uses the separately registered client_secret_post client described in step 2.

import express, { type Request } from "express";
import session, { type Session, type SessionData, type Store } from "express-session";
import passport from "passport";
import OAuth2Strategy from "passport-oauth2";

interface OAuthTransaction {
  tenantId: string;
}

interface OAuthResult {
  tenantId: string;
  oauthTokens: {
    accessToken: string;
    refreshToken: string;
  };
}

interface ApplicationSessionStore {
  set(sessionId: string, result: OAuthResult): void;
}

type ApplicationSession = Session &
  Partial<SessionData> & {
    oauthTransaction?: OAuthTransaction;
  };

const CLIENT_ID = "<PASSPORT_CLIENT_ID>";
const EXPECTED_AUDIENCE = "<YOUR_APPLICATION_RESOURCE_SERVER>";
const EXPECTED_ISSUER = "<YOUR_CONFIGURED_ISSUER>";
const REQUIRED_SCOPES = ["<custom_scope_1>"];
const INTROSPECTION_URL = "<YOUR_API_DOMAIN>/auth/oauth/introspect";

const app = express();
const serverSideSessionStore = app.get("serverSideSessionStore") as Store;
const applicationSessionStore = app.get("applicationSessionStore") as ApplicationSessionStore;

function mustGetEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return value;
}

function resolveAllowedTenant(req: Request): string {
  const tenantId = typeof req.query.tenantId === "string" ? req.query.tenantId : "public";
  const allowedTenants = mustGetEnv("ALLOWED_TENANT_IDS").split(",");
  if (!allowedTenants.includes(tenantId)) throw new Error("Invalid tenant");
  return tenantId;
}

function getApplicationSession(req: Request): ApplicationSession {
  return req.session as unknown as ApplicationSession;
}

class SuperTokensOAuth2Strategy extends OAuth2Strategy {
  authorizationParams(options: { tenantId?: string }): { tenant_id: string | undefined } {
    return { tenant_id: options.tenantId };
  }
}

async function introspectAndValidateTenant(accessToken: string, expectedTenant: string) {
  const response = await fetch(INTROSPECTION_URL, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({ token: accessToken, scope: REQUIRED_SCOPES.join(" ") }),
  });
  if (!response.ok) throw new Error("OAuth introspection failed");

  const tokenInfo = (await response.json()) as {
    active?: boolean;
    aud?: string | string[];
    tId?: string;
    client_id?: string;
    iss?: string;
    sub?: string;
  };
  const audiences = Array.isArray(tokenInfo.aud) ? tokenInfo.aud : [tokenInfo.aud];
  if (
    tokenInfo.active !== true ||
    tokenInfo.tId !== expectedTenant ||
    tokenInfo.client_id !== CLIENT_ID ||
    tokenInfo.iss !== EXPECTED_ISSUER ||
    !audiences.includes(EXPECTED_AUDIENCE)
  ) {
    throw new Error("OAuth token does not match the login transaction");
  }
  if (typeof tokenInfo.sub !== "string") throw new Error("OAuth subject missing");
  return tokenInfo;
}

app.use(
  session({
    secret: mustGetEnv("APPLICATION_SESSION_SECRET"),
    store: serverSideSessionStore,
    resave: false,
    saveUninitialized: false,
    cookie: { httpOnly: true, secure: true, sameSite: "lax" },
  }),
);
app.use(passport.initialize());
app.use(passport.session());

passport.use(
  new SuperTokensOAuth2Strategy(
    {
      authorizationURL: "<YOUR_API_DOMAIN>/auth/oauth/auth",
      tokenURL: "<YOUR_API_DOMAIN>/auth/oauth/token",
      clientID: CLIENT_ID,
      clientSecret: mustGetEnv("OAUTH_CLIENT_SECRET"),
      callbackURL: "https://<YOUR_APPLICATION_DOMAIN>/oauth/callback",
      scope: "offline_access <custom_scope_1> <custom_scope_2>",
      state: true,
      pkce: true,
      passReqToCallback: true,
    },
    async (req, accessToken, refreshToken, params, profile, done) => {
      try {
        const applicationSession = getApplicationSession(req);
        const transaction = applicationSession.oauthTransaction;
        delete applicationSession.oauthTransaction;
        if (transaction === undefined) throw new Error("OAuth transaction missing");

        const tokenInfo = await introspectAndValidateTenant(accessToken, transaction.tenantId);
        const user = { id: tokenInfo.sub };
        done(null, user, {
          oauthTokens: { accessToken, refreshToken },
          tenantId: transaction.tenantId,
        });
      } catch (error) {
        done(error);
      }
    },
  ),
);

app.get("/login", (req, res, next) => {
  const tenantId = resolveAllowedTenant(req);
  getApplicationSession(req).oauthTransaction = { tenantId };
  const options = { tenantId } as passport.AuthenticateOptions & { tenantId: string };
  passport.authenticate("oauth2", options)(req, res, next);
});

app.get("/oauth/callback", (req, res, next) => {
  const completeAuthentication = (error: unknown, user: Express.User | false | null, info: OAuthResult | undefined) => {
    if (error || !user) return next(error ?? new Error("OAuth login failed"));
    if (!info?.oauthTokens || typeof info.tenantId !== "string") {
      return next(new Error("OAuth transaction result missing"));
    }

    getApplicationSession(req).regenerate((regenerateError) => {
      if (regenerateError) return next(regenerateError);
      req.logIn(user, (loginError) => {
        if (loginError) return next(loginError);
        applicationSessionStore.set(req.sessionID, {
          tenantId: info.tenantId,
          oauthTokens: info.oauthTokens,
        });
        res.redirect("/");
      });
    });
  };
  passport.authenticate("oauth2", { session: false }, completeAuthentication)(req, res, next);
});

passport-oauth2 generates and consumes its state and PKCE verifier in the initiating req.session. The separate oauthTransaction binds the allowlisted tenant to that same session, and authorizationParams sends it as the released tenant_id authorization parameter. The trusted introspection endpoint validates signature, expiry, revocation, and required scopes before the example compares the released tId, issuer, client, and audience fields. The rotated opaque application session stores the validated tenant and tokens server-side.

Use golang.org/x/oauth2 with a one-time, server-side transaction store. The store must key each transaction by the initiating application session ID; Consume must atomically read and delete it.

import (
  "context"
  "crypto/rand"
  "crypto/subtle"
  "encoding/base64"
  "encoding/json"
  "errors"
  "io"
  "net/http"
  "net/url"
  "strings"

  "golang.org/x/oauth2"
)

type OAuthTransaction struct {
  State    string
  Verifier string
  TenantID string
}

type Audience []string

func (audience *Audience) UnmarshalJSON(data []byte) error {
  var single string
  if err := json.Unmarshal(data, &single); err == nil {
    *audience = Audience{single}
    return nil
  }

  var multiple []string
  if err := json.Unmarshal(data, &multiple); err != nil {
    return errors.New("invalid OAuth audience")
  }
  *audience = multiple
  return nil
}

func (audience Audience) Contains(expected string) bool {
  for _, value := range audience {
    if value == expected {
      return true
    }
  }
  return false
}

type IntrospectionResponse struct {
  Active   bool     `json:"active"`
  Audience Audience `json:"aud"`
  TenantID string   `json:"tId"`
  ClientID string   `json:"client_id"`
  Issuer   string   `json:"iss"`
}

type AppSession struct {
  TenantID string
  Token    *oauth2.Token
}

type OAuthTransactionStore interface {
  Put(sessionID string, transaction OAuthTransaction) error
  Consume(sessionID string) (OAuthTransaction, bool)
}

type AppSessionStore interface {
  Put(sessionID string, session AppSession) error
}

type OAuthApp struct {
  Config                   *oauth2.Config
  IntrospectionURL         string
  ExpectedIssuer           string
  ExpectedAudience         string
  RequiredScopes           []string
  Transactions             OAuthTransactionStore
  Sessions                 AppSessionStore
  ResolveAllowedTenant     func(*http.Request) (string, error)
  ApplicationSessionID     func(*http.Request) string
  RotateApplicationSession func(http.ResponseWriter, *http.Request) (string, error)
}

func randomURLSafeToken(size int) (string, error) {
  value := make([]byte, size)
  if _, err := rand.Read(value); err != nil {
    return "", err
  }
  return base64.RawURLEncoding.EncodeToString(value), nil
}

func (app *OAuthApp) introspectAndValidateTenant(ctx context.Context, accessToken, expectedTenant string) error {
  form := url.Values{
    "token": {accessToken},
    "scope": {strings.Join(app.RequiredScopes, " ")},
  }
  request, err := http.NewRequestWithContext(ctx, http.MethodPost, app.IntrospectionURL, strings.NewReader(form.Encode()))
  if err != nil {
    return err
  }
  request.Header.Set("content-type", "application/x-www-form-urlencoded")

  response, err := http.DefaultClient.Do(request)
  if err != nil {
    return err
  }
  defer response.Body.Close()
  if response.StatusCode != http.StatusOK {
    return errors.New("OAuth introspection failed")
  }

  var tokenInfo IntrospectionResponse
  if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&tokenInfo); err != nil {
    return err
  }
  if !tokenInfo.Active || !tokenInfo.Audience.Contains(app.ExpectedAudience) || tokenInfo.TenantID != expectedTenant || tokenInfo.ClientID != app.Config.ClientID || tokenInfo.Issuer != app.ExpectedIssuer {
    return errors.New("OAuth token does not match the login transaction")
  }
  return nil
}

func (app *OAuthApp) Login(w http.ResponseWriter, r *http.Request) {
  state, err := randomURLSafeToken(32)
  if err != nil {
    http.Error(w, "login unavailable", http.StatusInternalServerError)
    return
  }
  tenantID, err := app.ResolveAllowedTenant(r)
  if err != nil {
    http.Error(w, "invalid tenant", http.StatusBadRequest)
    return
  }
  verifier := oauth2.GenerateVerifier()
  transaction := OAuthTransaction{State: state, Verifier: verifier, TenantID: tenantID}
  if err := app.Transactions.Put(app.ApplicationSessionID(r), transaction); err != nil {
    http.Error(w, "login unavailable", http.StatusInternalServerError)
    return
  }

  authURL := app.Config.AuthCodeURL(
    state,
    oauth2.S256ChallengeOption(verifier),
    oauth2.SetAuthURLParam("tenant_id", tenantID),
  )
  http.Redirect(w, r, authURL, http.StatusFound)
}

func (app *OAuthApp) Callback(w http.ResponseWriter, r *http.Request) {
  transaction, ok := app.Transactions.Consume(app.ApplicationSessionID(r))
  providedState := r.URL.Query().Get("state")
  if !ok || subtle.ConstantTimeCompare([]byte(transaction.State), []byte(providedState)) != 1 {
    http.Error(w, "invalid OAuth state", http.StatusBadRequest)
    return
  }

  token, err := app.Config.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(transaction.Verifier))
  if err != nil {
    http.Error(w, "code exchange failed", http.StatusBadRequest)
    return
  }
  if err := app.introspectAndValidateTenant(r.Context(), token.AccessToken, transaction.TenantID); err != nil {
    http.Error(w, "token validation failed", http.StatusUnauthorized)
    return
  }

  newSessionID, err := app.RotateApplicationSession(w, r)
  if err != nil {
    http.Error(w, "session creation failed", http.StatusInternalServerError)
    return
  }
  if err := app.Sessions.Put(newSessionID, AppSession{TenantID: transaction.TenantID, Token: token}); err != nil {
    http.Error(w, "session creation failed", http.StatusInternalServerError)
    return
  }
  http.Redirect(w, r, "/", http.StatusFound)
}

Create an OAuthApp with your OAuth client configuration, server-side stores, and application session helpers, then register its Login and Callback methods as HTTP handlers. ResolveAllowedTenant must reject tenants outside your allowlist. ApplicationSessionID must identify the initiating server-side session, and RotateApplicationSession must invalidate the old session and issue a new opaque session ID. Transactions.Consume must atomically read and delete an unexpired transaction; return false for missing or expired transactions.

Set IntrospectionURL to <YOUR_API_DOMAIN>/auth/oauth/introspect, ExpectedIssuer to the exact configured issuer, ExpectedAudience to this resource server, and RequiredScopes to its scopes. Core 12.1.1 introspection can return aud as a JSON string or array; Audience.UnmarshalJSON handles both and the callback requires the configured audience before creating a session. The released tenant_id authorization parameter selects the tenant, and introspection returns its signed tId. Only the rotated opaque session ID reaches the browser.

Use Authlib with a one-time server-side transaction store. These functions are framework-agnostic; connect redirect, request_url, and the session helpers to your framework.

import secrets
from typing import Dict, Mapping, Optional, Protocol, TypedDict, cast

import requests
from authlib.common.security import generate_token
from authlib.integrations.requests_client import OAuth2Session  # pyright: ignore[reportMissingTypeStubs]

CLIENT_ID = "<CLIENT_ID>"
CLIENT_SECRET = "<CLIENT_SECRET>"
AUTHORIZATION_URL = "<YOUR_API_DOMAIN>/auth/oauth/auth"
TOKEN_URL = "<YOUR_API_DOMAIN>/auth/oauth/token"
INTROSPECTION_URL = "<YOUR_API_DOMAIN>/auth/oauth/introspect"
CALLBACK_URL = "https://<YOUR_APPLICATION_DOMAIN>/oauth/callback"
EXPECTED_ISSUER = "<YOUR_CONFIGURED_ISSUER>"
EXPECTED_AUDIENCE = "<YOUR_APPLICATION_RESOURCE_SERVER>"
SCOPES = ["offline_access", "<custom_scope_1>", "<custom_scope_2>"]
REQUIRED_SCOPES = ["<custom_scope_1>"]


class OAuthTransaction(TypedDict):
    state: str
    verifier: str
    tenant_id: str


class ApplicationSession(TypedDict):
    tenant_id: str
    oauth_token: Mapping[str, object]


class TransactionStore(Protocol):
    def put(self, session_id: str, transaction: OAuthTransaction) -> None: ...

    def consume(self, session_id: str) -> Optional[OAuthTransaction]: ...


class ApplicationSessionStore(Protocol):
    def put(self, session_id: str, session: ApplicationSession) -> None: ...


class InvalidOAuthState(ValueError):
    pass


def resolve_allowed_tenant() -> str:
    raise NotImplementedError


def application_session_id() -> str:
    raise NotImplementedError


def request_query_parameter(name: str) -> Optional[str]:
    raise NotImplementedError


def request_url() -> str:
    raise NotImplementedError


def rotate_application_session() -> str:
    raise NotImplementedError


def redirect(url: str) -> str:
    raise NotImplementedError


def introspect_and_validate_tenant(access_token: str, expected_tenant: str) -> None:
    response = requests.post(
        INTROSPECTION_URL,
        data={"token": access_token, "scope": " ".join(REQUIRED_SCOPES)},
        timeout=5,
    )
    response.raise_for_status()
    token_info = response.json()
    audiences = token_info.get("aud", [])
    if isinstance(audiences, str):
        audiences = [audiences]
    if (
        token_info.get("active") is not True
        or token_info.get("tId") != expected_tenant
        or token_info.get("client_id") != CLIENT_ID
        or token_info.get("iss") != EXPECTED_ISSUER
        or EXPECTED_AUDIENCE not in audiences
    ):
        raise ValueError("OAuth token does not match the login transaction")


def login(transaction_store: TransactionStore) -> str:
    client = OAuth2Session(
        CLIENT_ID,
        CLIENT_SECRET,
        token_endpoint_auth_method="client_secret_basic",
        scope=SCOPES,
        redirect_uri=CALLBACK_URL,
        code_challenge_method="S256",
    )
    verifier = generate_token(48)
    tenant_id = resolve_allowed_tenant()
    authorization_url, state = cast(
        tuple[str, str],
        client.create_authorization_url(  # pyright: ignore[reportUnknownMemberType]
            AUTHORIZATION_URL,
            code_verifier=verifier,
            tenant_id=tenant_id,
        ),
    )
    transaction_store.put(
        application_session_id(),
        {"state": state, "verifier": verifier, "tenant_id": tenant_id},
    )
    return redirect(authorization_url)


def callback(
    transaction_store: TransactionStore,
    application_session_store: ApplicationSessionStore,
) -> str:
    # consume atomically reads and deletes the initiating session's transaction
    transaction = transaction_store.consume(application_session_id())
    provided_state = request_query_parameter("state") or ""
    if transaction is None or not secrets.compare_digest(
        transaction["state"], provided_state
    ):
        raise InvalidOAuthState()

    client = OAuth2Session(
        CLIENT_ID,
        CLIENT_SECRET,
        token_endpoint_auth_method="client_secret_basic",
        state=transaction["state"],
        redirect_uri=CALLBACK_URL,
        code_challenge_method="S256",
    )
    token = cast(
        Dict[str, object],
        client.fetch_token(  # pyright: ignore[reportUnknownMemberType]
            TOKEN_URL,
            authorization_response=request_url(),
            code_verifier=transaction["verifier"],
        ),
    )
    access_token = token.get("access_token")
    if not isinstance(access_token, str):
        raise ValueError("OAuth access token missing")
    introspect_and_validate_tenant(access_token, transaction["tenant_id"])

    new_session_id = rotate_application_session()
    application_session_store.put(
        new_session_id,
        {"tenant_id": transaction["tenant_id"], "oauth_token": token},
    )
    return redirect("/")

Set INTROSPECTION_URL to <YOUR_API_DOMAIN>/auth/oauth/introspect, EXPECTED_ISSUER to the exact configured issuer, and configure the expected client, audience, and required scopes. Authlib sends the released tenant_id authorization parameter. Core introspection validates signature, expiry, revocation, and requested scopes and returns the signed tId; only the rotated opaque session ID is sent to the browser.

Use League OAuth2 Client with a server-side application session and one-time transaction store.

$clientSecret = getenv('OAUTH_CLIENT_SECRET');
if ($clientSecret === false) {
    throw new RuntimeException('OAUTH_CLIENT_SECRET is required');
}

$httpClient = new GuzzleHttp\Client(['timeout' => 5]);
$provider = new League\OAuth2\Client\Provider\GenericProvider(
    [
        'clientId' => CLIENT_ID,
        'clientSecret' => $clientSecret,
        'redirectUri' => 'https://<YOUR_APPLICATION_DOMAIN>/oauth/callback',
        'urlAuthorize' => '<YOUR_API_DOMAIN>/auth/oauth/auth',
        'urlAccessToken' => '<YOUR_API_DOMAIN>/auth/oauth/token',
        'urlResourceOwnerDetails' => '<YOUR_API_DOMAIN>/auth/oauth/userinfo',
        'scopes' => ['offline_access', '<custom_scope_1>', '<custom_scope_2>'],
        'scopeSeparator' => ' ',
        'pkceMethod' => League\OAuth2\Client\Provider\GenericProvider::PKCE_METHOD_S256,
    ],
    [
        'httpClient' => $httpClient,
        'optionProvider' => new League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider(),
    ],
);

if ($requestPath === '/login') {
    $tenantId = resolveAllowedTenant();
    $authorizationUrl = $provider->getAuthorizationUrl([
        'tenant_id' => $tenantId,
    ]);
    $transactionStore->put(session_id(), [
        'state' => $provider->getState(),
        'pkceCode' => $provider->getPkceCode(),
        'tenantId' => $tenantId,
    ]);
    header('Location: ' . $authorizationUrl);
    exit;
}

if ($requestPath !== '/oauth/callback') {
    throw new RuntimeException('Not found');
}

// consume atomically reads and deletes the initiating session's transaction
$transaction = $transactionStore->consume(session_id());
$providedState = $_GET['state'] ?? '';
if ($transaction === null || !hash_equals($transaction['state'], $providedState)) {
    throw new RuntimeException('Invalid OAuth state');
}
if (isset($_GET['error']) || !isset($_GET['code'])) {
    throw new RuntimeException('OAuth authorization failed');
}

$provider->setPkceCode($transaction['pkceCode']);
$token = $provider->getAccessToken('authorization_code', [
    'code' => $_GET['code'],
]);

$introspectionResponse = $httpClient->request('POST', INTROSPECTION_URL, [
    'form_params' => [
        'token' => $token->getToken(),
        'scope' => implode(' ', REQUIRED_SCOPES),
    ],
]);
$tokenInfo = json_decode(
    (string) $introspectionResponse->getBody(),
    true,
    512,
    JSON_THROW_ON_ERROR,
);
$audiences = is_array($tokenInfo['aud'] ?? null)
    ? $tokenInfo['aud']
    : [$tokenInfo['aud'] ?? null];
if (
    ($tokenInfo['active'] ?? false) !== true
    || ($tokenInfo['tId'] ?? null) !== $transaction['tenantId']
    || ($tokenInfo['client_id'] ?? null) !== CLIENT_ID
    || ($tokenInfo['iss'] ?? null) !== EXPECTED_ISSUER
    || !in_array(EXPECTED_AUDIENCE, $audiences, true)
) {
    throw new RuntimeException('OAuth token does not match the login transaction');
}

session_regenerate_id(true);
$applicationSessionStore->put(session_id(), [
    'tenantId' => $transaction['tenantId'],
    'oauthToken' => $token,
]);
header('Location: /');
exit;

Set INTROSPECTION_URL to <YOUR_API_DOMAIN>/auth/oauth/introspect, EXPECTED_ISSUER to the exact configured issuer, and configure the expected audience and required scopes. League sends the released tenant_id authorization parameter. Core introspection validates signature, expiry, revocation, and requested scopes and returns signed tId; only the rotated opaque session ID reaches the browser.

You can use the Spring Security library. Follow these instructions and implement it in your backend. You can determine the configuration parameters based on the response received in step 2.

  • client-id corresponds to clientId
  • client-secret corresponds to clientSecret
  • scope corresponds to scope
  • issuer-uri corresponds to <YOUR_API_DOMAIN>/auth

Use an OAuth2AuthorizationRequestResolver to add the allowlisted tenant as the tenant_id authorization parameter and retain it in the server-side AuthorizationRequestRepository transaction. After callback, call <YOUR_API_DOMAIN>/auth/oauth/introspect, require active, the configured scopes/client/audience/issuer, and exact tId, then persist that tenant and the tokens in the rotated server-side application session.

Use ASP.NET Core’s OpenID Connect authentication middleware to handle the authorization callback, correlation cookie, state, nonce, token validation, and application-session rotation. Configure its authority as <YOUR_API_DOMAIN>/auth, set ClientId and ClientSecret from the confidential client, and set CallbackPath to the path of an exact redirectUris entry. Store tokens server-side rather than in the authentication cookie.

In OnRedirectToIdentityProvider, add the allowlisted tenant to AuthenticationProperties.Items and send it as ProtocolMessage.SetParameter("tenant_id", tenantId). After callback, introspect the access token at <YOUR_API_DOMAIN>/auth/oauth/introspect; require active, the configured scopes/client/audience/issuer, and exact tId. Put that tenant and the tokens in the rotated server-side application session, never in the browser cookie.

6. Update the login flow in your frontend applications

In your frontend applications you need to add a login action that directs the user to the authentication page.

The user should first redirect to the backend authentication endpoint defined during the previous step. There the backend generates a safe authorization URL using the OAuth2 library and then redirects the user there. After login, the Authorization Service redirects the user to the backend callback. The backend verifies the bound state (and OIDC nonce when applicable), exchanges the code, rotates the application session, and sets only the hardened opaque session cookie described above.

7. Test the new authentication flow

With everything set up, you can test your login flow. Use the setup created in the previous step to check if the authentication flow completes without any issues.

API reference

API schema and response details