Staticbot logoStaticbot.dev

    Supabase Auth Across Multiple Nuxt Apps on Subdomains

    You split your monolith into app.example.com, admin.example.com, portal.example.com — one shared Supabase project, one user base, three Nuxt apps on Cloudflare Workers. The clean pattern is shorter than you'd expect: one cookie, one auth UI app, independent JWT verification. No custom SSO server, no postMessage dance.

    Published June 3, 20269 min read

    TL;DR

    1. Scope Supabase's session cookie to the parent domain (Domain=.example.com). All subdomains see the same session automatically.
    2. Let one app (e.g. auth.example.com) own login, callback, OAuth, password reset. Other apps redirect to it and rely on the shared cookie.
    3. Each Nitro backend verifies the JWT independently on every request. Stateless, horizontally scalable, no coordination.
    4. Share auth code across apps via a Nuxt layer, not by copy-paste.

    You don't need Supabase's "Custom SSO" feature for this. That's a different product solving a different problem (SAML/OIDC enterprise IdPs).

    The "I need an SSO server" instinct

    When developers split a single Nuxt app into three subdomains, the first instinct is usually: "I need an SSO server in the middle." That instinct generally comes from prior exposure to Keycloak, Auth0, or homegrown auth services that issue tokens to multiple clients. It's not wrong, exactly — it's just heavier than what Supabase actually needs.

    Supabase Auth already is the central identity provider. Your three Nuxt apps don't need to "talk to" a separate SSO server — they all already talk to the same Supabase project. The only real question is: how does a user logged into app.example.com appear logged-in on admin.example.com without re-entering credentials?

    The answer is the browser's cookie model, not a service.

    What "Supabase SSO" actually is (and why it's the wrong tool here)

    Supabase ships a feature literally named "Single Sign-On" in their dashboard. It's tempting to assume it's for the case at hand. It isn't. Supabase's SSO feature is for SAML 2.0 / OIDC enterprise identity federation — the case where your customer's IT team says "our employees must log in via our Okta / Azure AD / Workday account, never with a password." You wire Supabase to their IdP, users hit your app, get bounced to Okta, sign in there, get bounced back.

    That's not what you have. You have your own users logging into your own apps. The two problems share the abbreviation "SSO" and nothing else. Using Supabase's SSO feature for subdomain session sharing would be like installing a corporate firewall to filter spam in your inbox — wrong category.

    The load-bearing decision: parent-domain cookie scope

    The HTTP cookie spec already solved this in 1997. A cookie set with Domain=.example.com is sent by the browser to every subdomain of example.com — including app., admin., and portal.. You don't need to invent anything. You just need to tell @nuxtjs/supabase to set that domain instead of the default per-subdomain scope.

    In each app's nuxt.config.ts:

    export default defineNuxtConfig({
      modules: ['@nuxtjs/supabase'],
      supabase: {
        url: process.env.SUPABASE_URL,
        key: process.env.SUPABASE_ANON_KEY,
        cookieOptions: {
          domain: '.example.com',  // <- THE line that does the work
          path: '/',
          sameSite: 'lax',
          secure: true,
          maxAge: 60 * 60 * 8,
        },
        redirectOptions: {
          login: '/login',
          callback: '/auth/callback',
        },
      },
    });

    Every Nuxt app in the ecosystem sets the same cookieOptions.domain. The Supabase access token, refresh token, and session metadata are now all stored in cookies the whole second-level domain can read. A user who logs in on auth.example.com and navigates to admin.example.com arrives already authenticated — the browser sent the session cookie automatically, the module reads it, the user appears logged in. No redirect, no token exchange, no SDK ceremony.

    Don't do this on a public suffix.

    Setting a cookie at Domain=.vercel.app or Domain=.pages.dev won't work — browsers reject cookies scoped to public suffixes (see the Public Suffix List). You need your own apex domain. This is one more reason to put a custom domain on each subdomain in production.

    One Nuxt app owns the auth UI

    Login, signup, OAuth callbacks, password reset, magic links — all of that lives in a single small Nuxt app, e.g. auth.example.com. The other apps have no auth UI at all; if a user lands on admin.example.com unauthenticated, they get redirected to https://auth.example.com/login?return_to=https://admin.example.com/dashboard. After login, the auth app sets the parent-domain cookie and bounces the user back. No other app knows or cares how the cookie got there.

    In Supabase's project settings, your allowed redirect URLs collapse to a single canonical entry: https://auth.example.com/auth/callback. OAuth providers (Google, GitHub, Apple) only redirect to that one URL. Adding a fourth or fifth subdomain to your ecosystem later doesn't require touching Supabase config.

    Sample login page on the auth app:

    <!-- pages/login.vue on auth.example.com -->
    <script setup lang="ts">
    const supabase = useSupabaseClient();
    const route = useRoute();
    const returnTo = (route.query.return_to as string) ?? 'https://app.example.com';
    
    async function signInWithGoogle() {
      await supabase.auth.signInWithOAuth({
        provider: 'google',
        options: {
          redirectTo: `https://auth.example.com/auth/callback?return_to=${encodeURIComponent(returnTo)}`,
        },
      });
    }
    </script>

    And the callback:

    <!-- pages/auth/callback.vue -->
    <script setup lang="ts">
    const route = useRoute();
    const supabase = useSupabaseClient();
    
    const { data, error } = await supabase.auth.exchangeCodeForSession(
      route.query.code as string
    );
    
    if (!error) {
      // Cookie is now set on .example.com — every subdomain sees it.
      await navigateTo(
        (route.query.return_to as string) ?? 'https://app.example.com',
        { external: true }
      );
    }
    </script>

    Validate return_to against an allowlist of your own subdomains before redirecting — that prevents open-redirect attacks on your auth surface.

    Each Nitro backend verifies independently

    Every request to admin.example.com and portal.example.com arrives with the Supabase access token in a cookie. The Nitro server reads it and verifies it. No call to the auth app. No coordination.

    The simplest version uses @nuxtjs/supabase's serverSupabaseUser, which under the hood validates the JWT against the project's JWKS:

    // server/middleware/auth.ts
    import { serverSupabaseUser } from '#supabase/server';
    
    const PUBLIC_PATHS = ['/api/health', '/api/public/'];
    
    export default defineEventHandler(async (event) => {
      if (PUBLIC_PATHS.some((p) => event.path?.startsWith(p))) return;
    
      const user = await serverSupabaseUser(event);
      if (!user) {
        throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
      }
      event.context.user = user;
    });

    For the lowest possible latency on Cloudflare Workers — where every millisecond of cold start matters — you can verify the JWT signature locally against Supabase's JWKS without a network call to Supabase:

    // server/utils/verifyJwt.ts
    import { jwtVerify, createRemoteJWKSet } from 'jose';
    
    const JWKS = createRemoteJWKSet(
      new URL(`${process.env.SUPABASE_URL}/auth/v1/jwks`)
    );
    
    export async function verifyJwt(token: string) {
      const { payload } = await jwtVerify(token, JWKS, {
        issuer: `${process.env.SUPABASE_URL}/auth/v1`,
      });
      return payload; // { sub, email, role, ... }
    }

    jose's createRemoteJWKSet caches the JWKS in-process, so after the first request you're doing pure signature math — no network. This is the right shape for Cloudflare Workers' edge runtime where you want zero outbound calls in the auth hot path.

    Share auth code with a Nuxt layer, not copy-paste

    You're right to want one source of truth. The Nuxt-native answer is layers — a way to extend one Nuxt project from another, inheriting components, composables, server middleware, and config. Build a auth-layer/ directory with everything auth-related, then have each app extend it.

    Repo layout:

    packages/
      auth-layer/
        nuxt.config.ts         # @nuxtjs/supabase config + cookieOptions
        server/middleware/auth.ts
        composables/useAuthGuard.ts
        components/SignOutButton.vue
      apps/
        auth/                  # auth.example.com (login UI lives here only)
          nuxt.config.ts       # extends: ['../../packages/auth-layer']
        app/                   # app.example.com
          nuxt.config.ts       # extends: ['../../packages/auth-layer']
        admin/                 # admin.example.com
          nuxt.config.ts       # extends: ['../../packages/auth-layer']

    Now there's exactly one place the cookie domain, redirect URLs, JWKS verification, and auth middleware live. A change ripples to every app on the next build. If you ever publish the layer as a private npm package (@yourorg/nuxt-auth), the apps just bump the dependency. Bun's workspace support handles this natively without any monorepo tool.

    The realistic edge cases

    Refresh token rotation

    Supabase access tokens expire after 1 hour by default. The refresh token lives in a separate cookie scoped the same way (Domain=.example.com), so it's available across all subdomains. @nuxtjs/supabase handles refresh server-side automatically — the user never notices. The only gotcha: refresh requests serialise on the server side, so if two requests race the refresh, one waits. That's the right behaviour; don't try to "fix" it.

    CSRF

    SameSite=Lax on the session cookie blocks most cross-site request forgery vectors out of the box. For state-changing API calls from the browser, add a CSRF token in a request header (not a cookie) and verify it server-side. Nuxt has nuxt-csurf for this; on Cloudflare Workers, the double-submit cookie pattern works without any storage.

    Sign-out everywhere

    Calling supabase.auth.signOut() clears the cookie on the current domain scope. Because the cookie is at .example.com, the clear affects every subdomain. The user is signed out everywhere on next request. If you want immediate revocation (e.g. compromised account), use Supabase's admin API to signOut(user.id, 'global') — that invalidates the refresh token server-side so other tabs can't refresh themselves back into a session.

    RLS still just works

    Every subdomain's Nuxt app, when it calls Supabase from the browser or from a Nitro server route using serverSupabaseClient, sends the user's access token with each request. Supabase's PostgREST + RLS layer evaluates policies against auth.uid() as usual. There's nothing different about RLS in a multi-app setup — it's still per-request, per-user.

    Realtime across subdomains

    Each app opens its own Realtime websocket to Supabase, authenticated with the same access token from the shared cookie. If you want a "user opens admin in one tab, sees notification on app in another tab" pattern, broadcast through a Realtime channel keyed by user_id and subscribe from every app. That's a cross-tab problem, not a cross-domain one — Supabase doesn't care which subdomain the connection comes from.

    Anti-patterns to skip

    • Storing the session in localStorage and copying it via postMessage. Cross-origin postMessage between subdomains works but adds a synchronous handshake on every navigation. Worse, localStorage isn't readable from server routes, so SSR can't see the user. Cookies solve both problems.
    • Issuing your own JWTs on top of Supabase's. You'd be re-signing the same identity claim with a second key, doubling the rotation surface, and locking yourself out of Supabase's RLS (which reads auth.uid() from its token, not yours). Use Supabase's tokens directly.
    • Calling the auth app's API to validate each request. It's a network hop per request. Cloudflare Workers cold-starts already cost you milliseconds — don't add a synchronous internal call. JWKS verification is local cryptography after the first JWKS fetch.
    • Putting login pages in every app "just for convenience." You'll multiply the OAuth provider configuration, multiply the password-reset edge cases, and spread session-establishment logic across three codebases. Centralise it once and redirect.

    Cloudflare Workers specifics

    Three small things to know when each subdomain runs on its own Worker:

    • Use nitro.preset: 'cloudflare-module' (not cloudflare-pages unless you're actually on Pages). The module preset gives you the modern Workers runtime with native ESM and bindings.
    • The JWKS fetch is cold-start work, not request work. createRemoteJWKSet caches per-isolate. Cloudflare reuses isolates across requests aggressively, so after one cold start the JWKS lives in memory for the lifetime of the isolate — usually minutes. You can also pre-warm it by fetching JWKS at module init.
    • Bun is fine for build, not for runtime. Workers runs your built bundle on V8 with Cloudflare's runtime APIs — Bun is just doing the install + bundle. Don't try to ship Bun-specific APIs (Bun.serve, Bun.file) to a Worker.

    When you would actually want a real SSO server

    The pattern above breaks down in three cases. None of them apply to a single product split across subdomains.

    • You need multi-tenant enterprise SSO (each customer's Okta / Azure AD). Use Supabase's SSO feature for this, not a custom server.
    • You're federating with a non-Supabase identity provider that needs OAuth2 client credentials issued per app. Auth0, Logto, or Keycloak fit this.
    • Your apps live on different second-level domains (example.com + otherapp.io). Cookies can't span those — you'd need a real OAuth-flow handshake between the domains, which is the actual job description of an SSO server.

    The whole pattern, one paragraph

    Set Supabase's session cookie at Domain=.example.com. Put login, OAuth callbacks, and password reset in one Nuxt app at auth.example.com; every other app redirects there when unauthenticated. Each app's Nitro middleware verifies the JWT locally against Supabase's JWKS — no network call to Supabase, no internal RPC to your auth app. Share the config and middleware via a Nuxt layer so there's one file to change when you onboard the next subdomain. That's the entire architecture.

    It's not exotic. It's just what the cookie spec, JWTs, and Supabase's existing primitives already enable when you stop reaching for an SSO server you don't need.

    Hosting this on AWS instead?

    The pattern is identical on AWS — same cookie scope, same auth app, same JWKS verification. Staticbot can deploy each Nuxt app to its own S3 + CloudFront distribution under your custom subdomains with a single configuration, and wire DNS + ACM for you. If you want a starting template, the AWS-flavoured version of this setup is on our templates page.