A frustrated developer rubs their temples, surrounded by multiple screens showing abstract login forms, struggling with cross app SSO challenges.
Production engineering patternUpdated

Cross-App SSO with JWT & Refresh-Token Rotation Done Right

One auth issuer, short-lived JWTs with per-app audience claims, refresh tokens that rotate on every use — the cross-app SSO pattern BFEAI runs across seven production apps.

The problem

The first cross-app sign-in bug looks innocent. A user is signed in to the admin app, clicks a link that opens the customer app in a new tab, and lands on a sign-in screen. They re-enter their password. They get back to where they were going. Nobody files a ticket because it works. Then a second app joins the suite. Then a third. By the time the suite has five apps, every cross-app jump costs the user a password prompt, the support inbox fills with "I keep getting logged out" tickets, and someone on the team proposes a "shared session" hack that ends with a long-lived cookie containing the user's email and a shared secret.

Shared-cookie hack on the apex domain

That hack is the start of every cross-app authentication disaster. The cookie gets read by a third party because it is set on the apex domain. The shared secret leaks because it has to live in every app's environment. The session does not really end because each app has its own state and the sign-out button on one app cannot reach the others. A leaked credential keeps working for weeks because nobody knows where to revoke it. The team adds a redirect-based handshake that mostly works except in Safari, where it silently fails because of intelligent tracking prevention. The whole thing becomes a Jenga tower that the team is afraid to touch.

Enterprise security review fallout

The business consequences are concrete. Users churn because signing in to your suite feels harder than signing in to your competitor's. Enterprise prospects pull out of pilots when their security review finds shared secrets in environment variables. SOC 2 auditors flag the lack of session-revocation guarantees and the audit cycle stretches another month. A real incident — a stolen token, a leaked credential, an insider threat — turns into a multi-day forensic exercise because nobody can prove when the session ended or which app the attacker used.

Single-app auth cannot grow into a suite

The deeper problem is that "sign-in to one app" and "sign-in across a suite" are different architectures, and trying to grow the first into the second by stitching cookies together fails predictably. The pattern below is the architecture you actually want when you have more than one app sharing the same user base, and it is the same pattern BoostFrame Engineering AI runs across seven production apps today.

Three engineers collaborate, discussing a complex, colorful architectural diagram on a large monitor, designing a robust cross app SSO solution.

What changes for your business

The architecture has four pieces that have to work together: one JWT issuer for the whole suite, public-key distribution via JWKS, short-lived access tokens with per-app audience claims, and refresh tokens that rotate on every use with reuse detection. Drop any piece and you get a different system with weaker guarantees.

Single JWT issuer for the suite

Start with the issuer. Pick one — Supabase Auth, Auth0, Clerk, AWS Cognito, or a service you own — and make it the sole authority that mints tokens for the suite. Every app in the suite trusts the same issuer and nothing else. The issuer is responsible for one thing: turning a verified credential (email + password, OAuth code, magic link, passkey) into a signed JWT carrying the user's identity, tenant scope, and roles. It is also the only place that holds refresh tokens, the only place that can revoke a session, and the only place that knows the full set of apps a user is signed in to.

JWTs are emitted with at minimum these claims:

type AccessTokenClaims = {
  iss: "https://auth.example.com";   // shared issuer URL
  sub: string;                        // stable user id
  aud: string;                        // per-app audience: 'admin-app' | 'customer-app' | ...
  exp: number;                        // NumericDate, ~5-15 minutes from now
  iat: number;                        // issued at
  jti: string;                        // unique token id, used for denylist if you need instant revocation

  // App-readable scope — pulled from immutable app metadata, not user metadata
  tenant_id: string;
  roles: string[];
};

Per-app audience claims

RFC 7519 specifies that the audience claim identifies the recipients the JWT is intended for, and that recipients that do not identify themselves with a value in the audience claim when the claim is present must reject the JWT. Use that mechanism literally: mint tokens with aud: 'admin-app' for admin sign-in and aud: 'customer-app' for customer sign-in, so a token captured from one app cannot be replayed against another even though both verify the same issuer. The audience is enforced at verify time:

import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));

export async function verifyAccessToken(
  token: string,
  expectedAudience: string,
): Promise<AccessTokenClaims> {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: "https://auth.example.com",
    audience: expectedAudience,
    // jose enforces 'exp' automatically; clockTolerance covers small drift.
    clockTolerance: 30,
  });
  return payload as AccessTokenClaims;
}

JWKS-based key distribution

Public-key distribution is what makes the verifier portable. The issuer publishes a JWKS endpoint — a URL that returns the current set of public keys in JSON Web Key format. Each app fetches the JWKS at startup, caches it in memory, and refreshes when it sees a token signed with a kid it does not recognize. This is how you rotate signing keys without deploying every app: mint with a new kid, the JWKS endpoint returns both keys for a transition window, every app's verifier picks up the new key the first time it sees it, and once no tokens with the old kid are in flight, the old key is removed. The createRemoteJWKSet helper in jose handles fetch, cache, and refresh automatically. Hardcoding the public key works for one app and breaks the moment a key has to rotate, which is something you want to be able to do on five minutes' notice if a key ever leaks.

Short-lived access tokens

Access tokens are short — 5 to 15 minutes — for two reasons. First, propagation: when a user signs out or a role changes, every app's view of that user is stale until its current access token expires, so shorter TTL means shorter staleness. Second, blast radius: an access token captured from a log file, a server-side request, or a leaky URL is usable for at most its TTL. Short tokens are not free — they mean more refresh traffic to the issuer — but they are the right default unless you have a specific reason to lengthen them.

Rotating refresh tokens with reuse detection

Refresh tokens are long — 7 to 30 days — because they are how the user stays signed in across visits. They are also the high-value target. RFC 6749 defines refresh tokens as credentials intended for use only with authorization servers, not sent to resource servers, which is the property that makes the security model work: the refresh token only ever travels between the client and the issuer, and the issuer is the only place it can be exchanged. Apps in the suite do not see a refresh token at all — they receive an access token from the client on each request and verify it against the JWKS.

Rotation is the security mechanism that makes long-lived refresh tokens safe. Every time the client exchanges a refresh token for a new access token, the issuer returns a new refresh token alongside it, marks the old one as used, and refuses to honor the old one a second time. Supabase Auth implements this as single-use refresh tokens — a refresh token can only be used once — with a 10-second reuse window for race conditions where the network blips and the client legitimately retries with the same token. Outside that window, reusing a token means one of two things: an attacker stole it and used it before the real user could refresh, or the real user is on a slow connection and just took the loss. The auth provider cannot tell which, so it takes the safe action and revokes the entire session. Supabase's documentation describes this exactly: "the whole session is regarded as terminated and all refresh tokens belonging to it are marked as revoked."

Auth0 calls the same mechanism token-family invalidation: the original refresh token and every token that descends from it are killed at once, and every subsequent request is denied until the user re-authenticates. The implementation detail is the same and the security property is the same — a stolen refresh token works exactly once and surfaces itself the moment the real user tries to refresh.

Client-side coalesced refresh

The client-side refresh flow looks like this:

import jwt from "jsonwebtoken";

// Pseudocode for an SDK that wraps the auth provider's refresh endpoint.
// In practice you use the provider's SDK; the contract is the same.
type TokenPair = { access_token: string; refresh_token: string; expires_in: number };

let current: TokenPair | null = loadFromSecureStorage();
let refreshInFlight: Promise<TokenPair> | null = null;

export async function getAccessToken(): Promise<string> {
  if (!current) throw new Error("not signed in");

  const decoded = jwt.decode(current.access_token) as { exp: number };
  const msUntilExp = decoded.exp * 1000 - Date.now();

  // Refresh proactively at 30 seconds before expiry so the next request
  // does not race an expiring token.
  if (msUntilExp > 30_000) return current.access_token;

  // Coalesce concurrent refresh calls — every caller waits on the same
  // in-flight refresh instead of firing N parallel exchanges that would
  // trip reuse detection.
  refreshInFlight ??= rotateRefreshToken(current.refresh_token).finally(() => {
    refreshInFlight = null;
  });

  current = await refreshInFlight;
  saveToSecureStorage(current);
  return current.access_token;
}

async function rotateRefreshToken(refreshToken: string): Promise<TokenPair> {
  const res = await fetch("https://auth.example.com/token", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refreshToken }),
  });
  if (!res.ok) {
    // 401 here usually means the session was revoked — either the user
    // signed out from another app or reuse detection fired. Either way,
    // the client should drop local state and route the user to sign-in.
    clearSecureStorage();
    throw new Error("session revoked, re-authentication required");
  }
  return res.json();
}

Coalescing concurrent refresh calls is the part most implementations get wrong on the first pass. If two tabs of the same app both notice the access token is about to expire and both fire a refresh with the same refresh token, the first call rotates and the second call hits reuse detection — even though both came from the same legitimate user. The fix is the in-flight promise above: every caller within the same process waits on the same refresh, and only one network call actually rotates. The 10-second reuse window catches the cross-tab case where two separate processes do the same thing.

Sign-out propagation

Sign-out is the other piece most teams underspecify. The user clicks sign-out in any app. That app calls the issuer's revoke endpoint with the current refresh token. The issuer marks the refresh token as revoked. Every app in the suite keeps working until its current access token expires — at most 15 minutes — at which point the refresh call returns 401 and the user is sent to sign in again. Propagation latency equals the access-token TTL, which is the price of not running a denylist check on every request. If you need instant propagation — for compliance, for an admin "force sign-out" feature, or for high-stakes flows like fund transfers — add a revocation check on the jti claim against a Redis denylist. The cost is one Redis hit per request; the gain is sub-second sign-out across every app. Most suites do not need this for normal sign-out and do need it for the admin "force sign-out this user everywhere right now" button.

The "user logs out everywhere" requirement is the test for whether the architecture is right. With this pattern, the answer is: revoke the refresh token at the issuer, every app's session expires at the next refresh, done. Without this pattern — with per-app session storage, with separate auth per app, with the shared-cookie hack — the answer is some combination of "we cannot," "we have to clear cookies on each domain," and "the user has to sign out from each app manually." The first answer is what enterprise customers ask for in their security review. The other answers lose the deal.

A confident developer smiles, leaning back in their chair, viewing a clean, color-coded dashboard, reflecting a stable cross app SSO system.

More on this

What gets shipped

An engagement leaves your codebase with cross-app SSO that your team can extend to the next app in under a day. Concretely:

  • The auth issuer configured with the suite's apps as separate clients, each with its own audience identifier and redirect allowlist, plus the JWKS endpoint exposed at the standard .well-known/jwks.json path.
  • A shared verifier library (one package, imported by each app) that wraps jose's createRemoteJWKSet and jwtVerify, takes the expected audience as a parameter, and returns typed claims. New apps integrate by importing the verifier and passing their own audience string.
  • A typed client SDK that handles the access-token-refresh dance: proactive refresh 30 seconds before expiry, in-flight coalescing so concurrent calls do not trip reuse detection, secure storage with the correct flags for each platform (httpOnly cookie for SSR apps, secure storage for mobile, encrypted localStorage with caveats for SPAs).
  • A sign-out flow that revokes at the issuer first and clears local state second, with documentation for the "user logs out everywhere" UX — including the expected propagation latency and the optional Redis-denylist upgrade path for instant revocation.
  • A migration path from whatever your suite is doing today: per-app local sessions, a homegrown shared cookie, an older OAuth setup. The migration runs behind a feature flag so the new auth path is live for a percentage of traffic before it becomes the default, and the rollback is a flag flip.
  • A test suite that covers the cases that bite in production: concurrent refresh from two tabs, refresh after a 30-second network outage, sign-out from app A while signed in on apps B and C, reuse detection firing on a deliberately stolen token, and key rotation at the issuer without redeploying apps.
  • A runbook for the operational failure modes: JWKS endpoint outage (cached keys keep working until the cache expires), issuer downtime (signed-in users stay signed in until refresh, new sign-ins fail), reuse detection firing on legitimate traffic (most often a missing in-flight coalescer in the SDK), and a stolen-token incident (revoke the user's session at the issuer, force re-authentication, audit the logs for the source of the leak).

Common failure modes

Concurrent refresh without coalescing is the first sharp edge. Two tabs, two simultaneous refreshes, one of them trips reuse detection, the user is signed out unexpectedly. The fix is the in-flight promise pattern shown above. Cross-tab coordination — where the two tabs are separate processes — relies on the 10-second reuse window absorbing the race; this is exactly the case the window exists for.

The second is verifying with the wrong audience. A copy-paste from one app's verifier to another that did not update the audience parameter accepts tokens minted for a different app. Per-app audience is only protective if every verifier actually enforces its own audience. Add a startup-time check that the audience is set to the expected value and fail loud on missing config.

The third is reading tenant_id from mutable user metadata. Supabase splits user metadata into raw_user_meta_data (writable by the user via their own API key) and raw_app_meta_data (writable only by the service role). Putting tenant_id in user metadata means a user can change their own claim and read another tenant's data. Tenant scope and roles belong in app metadata. The same split exists in Auth0 (user_metadata vs app_metadata) and the same rule applies.

The fourth is the access-token-in-URL antipattern. Magic-link sign-in flows sometimes pass the new access token to the app via a URL fragment. URL fragments leak into browser history, referrer headers, and analytics tools. The pattern is to pass a short-lived one-time code and have the client exchange it for tokens via POST, which keeps the tokens out of the URL entirely.

The fifth is sign-out that clears local state but does not revoke at the issuer. The user clicks sign-out, the app deletes its cookies, the user sees a sign-in screen, and the refresh token is still live at the issuer — so an attacker who has the refresh token keeps using it until its natural expiry. Sign-out must call the revocation endpoint first, even if the user is about to close the tab; the local clear is the second step.

The sixth is the long-lived background job that holds an access token for hours. A worker that grabs a token at the start of an 8-hour run and uses it throughout fails halfway through when the token expires. Workers either need their own service-account credentials (preferred), or they need to wrap every request in the same access-token refresh dance the client SDK uses. Holding a single token across a long job is not viable with the short TTLs that make this architecture safe.

What to watch in your own implementation

Open your apps and answer four questions. First: does every app verify against the same issuer URL and the same JWKS endpoint, or did one of them get hardcoded with a stale public key? Run a check at startup that fetches the JWKS and logs the kid of every key it has cached — if two apps disagree, one of them is on a stale key and will stop working when rotation happens.

Second: does every verifier enforce the per-app audience? Grep for audience: in your verifier code and confirm each app is passing its own value. A test that mints a token with the wrong audience and asserts the verifier rejects it is the regression check that keeps this honest.

Third: does the client SDK coalesce concurrent refresh calls? Open two tabs of the same app, wait until the access token is about to expire, refresh both tabs simultaneously, and check the network panel. If you see two POSTs to the refresh endpoint with the same refresh token, the coalescer is missing and you will eventually trip reuse detection on a real user. The fix is the in-flight promise.

Fourth: does sign-out call the revocation endpoint at the issuer, and does it do so before clearing local state? A sign-out implementation that only clears cookies leaves the refresh token alive at the issuer until its natural expiry. Add a test that signs out from one app and asserts that a refresh attempt from a second app returns 401 within the access-token TTL — that is the property that makes "user logs out everywhere" actually work.

At BFEAI we run this architecture across seven production apps with the same issuer, the same verifier library, and the same client SDK. The access token TTL is 15 minutes, the refresh token TTL is 30 days, the reuse window is 10 seconds, and the jti denylist is wired but only consulted on a small set of high-stakes endpoints. Sign-out from any app revokes at the issuer and propagates everywhere within 15 minutes by default, or instantly on the denylist-protected endpoints. The pattern has caught two stolen tokens in production — both surfaced as reuse-detection events in the auth provider's log within minutes of the leak, both resolved by revoking the user's session and rotating the credential the token was tied to. Neither incident reached a customer.

Outcomes you should expect

What this delivers

  • Users sign in once and land in any app in the suite with their tenant scope intact, instead of re-authenticating on every property and writing a glue layer per app.
  • A leaked refresh token works exactly once — the next legitimate refresh by the real user triggers reuse detection and revokes the whole session, instead of granting an attacker indefinite tenant access.
  • Sign-out propagates within the access-token TTL — one click ends the session everywhere the user is signed in, instead of leaving zombie sessions alive until their tokens expire on their own.
  • Every app in the suite verifies the same JWT issuer with the same public key, so adding a new app means importing a verifier instead of inventing another auth flow.

Primary sources

By the numbers

  • RFC 7519 specifies that the JWT 'aud' (audience) claim identifies the recipients the JWT is intended for, and a recipient that does not identify itself with a value in the audience claim when the claim is present MUST reject the JWT.

    Source ↗

  • RFC 7519 requires that the current date/time MUST be before the expiration date/time listed in the 'exp' claim, and implementations MAY provide for some small leeway to account for clock skew, usually no more than a few minutes.

    Source ↗

  • RFC 6749 defines refresh tokens as credentials intended for use only with authorization servers and never sent to resource servers, used to obtain a new access token when the current access token becomes invalid or expires.

    Source ↗

  • Supabase Auth refresh tokens are single-use — a refresh token can only be used once — with a default 10-second reuse interval to accommodate server-side rendering handoffs and unreliable networks where a response may not reach the client.

    Source ↗

  • When a Supabase Auth refresh token is reused outside the reuse interval, the whole session is regarded as terminated and all refresh tokens belonging to it are marked as revoked, which is how stolen tokens get caught instead of granting indefinite access.

    Source ↗

  • Auth0 refresh token rotation invalidates the entire token family — all refresh tokens descending from the original refresh token issued for the client — when reuse is detected, and all subsequent requests are denied until the user re-authenticates.

    Source ↗

Live in production today

The same engineering, shipped in production at BFEAI.

I'm co-founder & CTO of Be Found Everywhere (BFEAI), a 7-app AI SaaS platform running today. The work I deliver for clients is the work I do every week on my own platform.

7

Production apps

200K+

Keywords generated

1,500+

AI scans run

7,000+

Sites automated

Common questions

What buyers ask before reaching out

Why a shared JWT issuer instead of separate auth per app?

Separate auth per app forces users to re-authenticate as they move between properties, and it forces your team to write a glue layer that synchronizes user state across providers. A shared issuer puts identity in one place — Supabase Auth, Auth0, Clerk, your own service — and every app verifies tokens against the same public key. The user experience becomes 'sign in once,' and the engineering experience becomes 'import a verifier' instead of 'integrate a new auth provider.'

How short should access tokens be, and how long should refresh tokens be?

Most teams I work with land between 5 and 15 minutes for access tokens and 7 to 30 days for refresh tokens, with the exact numbers driven by how fast you want sign-out to propagate versus how often you want to hit the auth provider. Shorter access tokens mean faster propagation and more refresh traffic. Longer refresh tokens mean fewer sign-in prompts and more exposure if a token leaks. The rotation pattern is what makes the long refresh TTL safe — a stolen token is single-use and gets caught the next time the real user refreshes.

What is the 10-second reuse window for?

Race conditions. The browser issues a refresh call, the network blips, the response never lands, and the client retries with the same refresh token. Without a reuse window, the second call looks like an attack and the session is revoked — even though it was the same legitimate user. Supabase's documentation calls out exactly this case: server-side rendering handoffs and unreliable networks where the response may not reach the client. A 10-second grace period catches the retry without weakening the security model.

What happens when reuse is detected outside the window?

The session is terminated and all refresh tokens belonging to it are revoked. Auth0 calls this token-family invalidation: every refresh token descending from the original is killed at once, and every app in the suite stops accepting the user's tokens at the next refresh. The user re-authenticates, the attacker's stolen token is dead, and your incident response is a log line instead of a forensic investigation.

How does sign-out propagate to other apps?

The access token is short-lived by design — within its TTL, every app keeps trusting it. When the user clicks sign-out, the auth provider revokes the refresh token. Each app keeps working until its current access token expires, then the refresh call fails, and the user is forced to re-authenticate. Propagation latency equals access-token TTL. If you need instant propagation across all apps, you add a revocation check on the JWT's 'jti' claim against a denylist — the cost is one Redis hit per request, the gain is sub-second sign-out everywhere.

Why per-app audience claims?

Because a JWT minted for the admin app should not be accepted by the customer app, and vice versa. RFC 7519 specifies that when the 'aud' claim is present, every recipient must identify itself with a value in that claim or reject the token. Setting aud=admin-app on tokens issued for admin sign-in and aud=customer-app on tokens for the customer app means a token captured from one app cannot be replayed against another — even though they share the same issuer.

How do you distribute the issuer's public key to every app?

Publish a JWKS endpoint — a URL the issuer hosts that returns the current set of public keys in JSON Web Key format. Every app fetches the JWKS at startup, caches it, and refreshes it on a schedule or when a key ID it has not seen arrives in a token header. This is how key rotation works: the issuer mints tokens with a new 'kid', apps see the new kid, fetch the JWKS, find the new public key, and verify without a deploy. Hardcoding the key works for one app but does not survive key rotation, and key rotation is something you want to be able to do on five minutes' notice if a key ever leaks.

Should the JWT carry tenant_id and roles, or just user_id?

Carry tenant_id and roles. The whole point of pushing identity into the JWT is to let downstream code — RLS policies, API handlers, edge middleware — read tenant scope and authorization directly from the token without a database round-trip. Put the claims in immutable app metadata (Supabase's raw_app_meta_data, Auth0's app_metadata) so the user cannot edit them client-side. Reserve mutable user metadata for things that genuinely belong to the user, like display name and locale.

Do I really need rotation, or can I just shorten the refresh token TTL?

You can shorten the TTL and ship something safer than long-lived static refresh tokens. The reason teams add rotation is that rotation gives you reuse detection — the moment a stolen token is used after the real user refreshes, the session dies. Without rotation, a stolen token works for its full TTL with no signal to the auth provider that anything is wrong. Rotation is the difference between 'we shortened the window' and 'we will know the moment something is stolen.'

Ready to see if this is a fit?

A 15-minute call. No deck, no slides. We talk about what you're shipping and where engineering is the bottleneck. Either way, you walk away with a senior engineer's read on your situation.