Article · May 15, 2026

How to build a Chrome extension popup with Supabase Auth (step by step)

Load Supabase JS into a Manifest V3 popup, persist sessions in chrome.storage, handle popup-reopen state recovery. Step by step.

The Supabase JS SDK is built for browsers that load it via <script type="module"> or an npm-bundled web app. A Chrome extension popup is neither. The popup is HTML loaded from chrome-extension://<id>/popup.html, with a content security policy that bans inline scripts and remote code, and no module loader by default. The naive Supabase quickstart does not work without three adjustments.

This tutorial covers the adjustments. The end state is a popup that lets users log in, stays logged in across browser restarts, and signs out cleanly. The auth UI is whatever shadcn-flavored HTML you write; the auth logic is 80 lines of vanilla JS.

Why MV3 makes this harder than a web app

Three constraints that change the shape:

Constraint 1: no module loader. The popup runs in a special HTML document loaded by Chrome. By default, <script type="module"> works only if the popup HTML is served with the right MIME type and the modules resolve to other files in the extension. In practice, importing from node_modules does not work. You bundle dependencies into your own JS files before they ship.

Constraint 2: strict content security policy. MV3’s default CSP for extension pages bans inline scripts (<script>...</script>) and remote code loading (loading JS from a URL at runtime). Every line of code has to be in an external file in the extension bundle.

Constraint 3: ephemeral popup. The popup is a tiny window that opens when the user clicks the extension icon and closes when they click anywhere else. State in popup-scope JavaScript variables disappears the moment the popup closes. You persist auth state in chrome.storage, which survives popup close and browser restart.

Step 1: scaffold the popup with HTML and a sibling script tag

The popup is one HTML file with a <script src> to an external JS file. No inline scripts, no module imports.

<!-- extension/popup.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <link rel="stylesheet" href="popup.css" />
</head>
<body>
  <div id="app">
    <!-- Login form, populated by JS -->
  </div>

  <!-- Supabase SDK as UMD bundle (more on this in step 2) -->
  <script src="supabase.js"></script>

  <!-- Your popup logic -->
  <script src="popup.js"></script>
</body>
</html>

The <script> tags load in order. supabase.js runs first and attaches a global supabase object to window. popup.js then uses that global to initialize the auth client. No imports, no module loader, no bundler complexity inside the extension itself.

The manifest.json points at this HTML as the popup:

{
  "manifest_version": 3,
  "name": "Your Extension",
  "version": "1.0.0",
  "action": {
    "default_popup": "popup.html"
  },
  "permissions": ["storage"],
  "host_permissions": ["https://your-project-ref.supabase.co/*"]
}

Three things in the manifest matter for auth. storage permission so the popup can use chrome.storage.local. host_permissions so the popup can make fetch calls to your Supabase URL. The popup HTML reference itself.

Step 2: bundle the Supabase UMD into the extension folder

The Supabase JS SDK ships several formats. The one you want for a popup is the UMD build (Universal Module Definition, which exposes the library as a global when loaded via <script src>).

The SDK does not publish a UMD build in the npm package by default; you build one yourself or copy from the SDK’s dist folder. The pattern I use is a small Node script that copies the UMD bundle from node_modules into extension/ on every build:

// scripts/bundle-extension.mjs
import { cp } from 'node:fs/promises';

await cp(
  'node_modules/@supabase/supabase-js/dist/umd/supabase.js',
  'extension/supabase.js'
);

console.log('Bundled Supabase UMD into extension/');

Run it once after npm install and re-run whenever you bump the SDK version:

// package.json
{
  "scripts": {
    "build:extension": "node scripts/bundle-extension.mjs"
  }
}

Now extension/supabase.js exists, the popup HTML loads it via <script src>, and the global supabase is available in popup.js. No webpack, no esbuild, no module loader in the extension itself.

A flow diagram showing the build pipeline: node_modules contains the Supabase UMD, a bundle script copies it into extension/, the popup HTML script tag loads it, the popup JS uses the global. Each step labeled with the file name and a short description.

Step 3: wire the auth UI

The popup JS initializes the auth client and renders the login UI:

// extension/popup.js
const SUPABASE_URL = 'https://your-project-ref.supabase.co';
const SUPABASE_ANON_KEY = 'your-anon-public-key';

// Initialize the client (uses the global from the UMD)
const sb = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
  auth: {
    storage: chromeStorageAdapter, // see step 4
    persistSession: true,
    autoRefreshToken: true,
  },
});

async function init() {
  // Check if we already have a session
  const { data: { session } } = await sb.auth.getSession();

  if (session) {
    renderLoggedIn(session.user);
  } else {
    renderLoginForm();
  }
}

function renderLoginForm() {
  document.getElementById('app').innerHTML = `
    <h1>Sign in</h1>
    <form id="loginForm">
      <label>Email <input type="email" id="email" required /></label>
      <label>Password <input type="password" id="password" required /></label>
      <button type="submit">Sign in</button>
      <p id="error" class="err" hidden></p>
    </form>
  `;

  document.getElementById('loginForm').addEventListener('submit', async (e) => {
    e.preventDefault();
    const email = document.getElementById('email').value;
    const password = document.getElementById('password').value;
    const errEl = document.getElementById('error');

    const { data, error } = await sb.auth.signInWithPassword({ email, password });

    if (error) {
      errEl.textContent = error.message;
      errEl.hidden = false;
      return;
    }

    renderLoggedIn(data.user);
  });
}

function renderLoggedIn(user) {
  document.getElementById('app').innerHTML = `
    <h1>Signed in as ${user.email}</h1>
    <button id="signOut">Sign out</button>
  `;
  document.getElementById('signOut').addEventListener('click', async () => {
    await sb.auth.signOut();
    renderLoginForm();
  });
}

init();

That is the whole popup. Two screens (login and signed-in), one form, two buttons. The sb.auth.signInWithPassword call is the standard Supabase auth pattern; the popup wraps it with the minimum HTML.

For an invite-only product, do not expose a signup link. The login form is login-only. The pattern for that is the same as the web-app version covered in two-layer identity models in Supabase: disable public signup at the project level and rely on the service-role invite path for new users.

Step 4: persist the session in chrome.storage.local

Default Supabase Auth persists the session in localStorage, which works in a normal web page. In an MV3 popup, localStorage exists per popup instance and is wiped when the popup closes. The session does not survive across popup opens.

The fix is to pass a custom storage adapter that maps to chrome.storage.local:

const chromeStorageAdapter = {
  getItem: async (key) => {
    const result = await chrome.storage.local.get([key]);
    return result[key] ?? null;
  },
  setItem: async (key, value) => {
    await chrome.storage.local.set({ [key]: value });
  },
  removeItem: async (key) => {
    await chrome.storage.local.remove([key]);
  },
};

Three methods (getItem, setItem, removeItem) implementing the Web Storage interface on top of the chrome.storage.local API. Supabase’s auth library calls these to save and load the session JSON.

chrome.storage.local persists across browser restarts, popup closes, and Chrome updates. It is per-extension, isolated from web-page localStorage. The session lives there until the user explicitly signs out (which calls removeItem) or the storage quota is exceeded (which does not happen for an auth session, which is a few KB).

Step 5: handle popup reopen (restore state without re-authenticating)

The init() function in step 3 already handles this. When the popup opens, it calls sb.auth.getSession(), which reads from the storage adapter (which reads from chrome.storage.local). If a session exists, the popup renders the signed-in view immediately. If not, the login form.

The user experience: first open, login form, user types credentials, signed in. Close the popup. Open again. Signed in (no login required). Restart the browser. Open again. Still signed in.

The session has an expiry (typically 1 hour for the access token, 7 days for the refresh token, depending on your Supabase project settings). When the access token expires, the autoRefreshToken: true option on the client tells the SDK to use the refresh token to fetch a new access token automatically. The user does not see this; the popup just keeps working.

When the refresh token also expires (7 days of no activity), the next getSession() returns null and the popup shows the login form. The user logs in again, the cycle restarts.

Step 6: sign out cleanly

The sign-out button calls sb.auth.signOut(), which:

  1. Calls the Supabase server to invalidate the session (so the refresh token is revoked server-side).
  2. Calls storage.removeItem on the storage adapter, which deletes the session from chrome.storage.local.
  3. Triggers an onAuthStateChange event you can listen to in other parts of the extension.

The popup then re-renders the login form. Clean state, no leftover credentials, no half-signed-out limbo.

Gotcha: CSP errors and the script-src declaration

If you load any external resource from your popup (a CDN font, a remote analytics script, a third-party library), the default MV3 content security policy blocks it. The browser console shows errors like “Refused to load the script because it violates the following Content Security Policy directive.”

The fix is to extend the CSP in the manifest:

{
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'"
  }
}

script-src 'self' allows JavaScript only from your own extension. Remote scripts are blocked. This is what you want; the lesson is that the Supabase UMD must live in the extension folder, not on a CDN. (This is also why step 2’s bundle script exists.)

If you absolutely need a remote resource (e.g. Google Fonts), you can add the specific origin to the CSP. The fewer external origins, the faster the review and the less attack surface.

Gotcha: redirect URI for OAuth (the chrome-extension:// scheme)

If you want to add OAuth providers (Google, GitHub) instead of email-password, the redirect URI is non-obvious. The browser will not let you redirect from accounts.google.com to chrome-extension://<id>/, so the normal OAuth flow does not work.

Two patterns work:

  1. Use chrome.identity.launchWebAuthFlow. Chrome provides an identity API that opens an OAuth popup, captures the redirect, and returns the result to your extension. The redirect URI is https://<extension-id>.chromiumapp.org/ (a Chrome-managed URL).
  2. Use a hosted callback page. Your OAuth provider redirects to https://your-website.com/auth/callback, which extracts the token from the URL and passes it back to the extension via chrome.runtime.sendMessage or postMessage. More complex, but it works if your provider does not support the chromiumapp.org scheme.

For most internal-tool extensions, email-password is enough and OAuth complexity is not worth it. Add it later if users ask.

What this tutorial does not cover

The scope I left out:

  • Service workers. The popup pattern above is “everything in the popup,” with state in chrome.storage. A more complex extension might have a background service worker that handles long-running tasks. The auth pattern is the same (the service worker uses the same Supabase client), but the wiring is different.
  • Content scripts. Scripts injected into other web pages by your extension can also use the Supabase client, but cross-frame messaging is non-trivial. Out of scope here.
  • Cross-device sync. Chrome can sync chrome.storage.sync (a separate storage area) across the user’s Chrome installs. For auth sessions, you usually do not want this (different devices should have different sessions for security). Use chrome.storage.local.

The shipping-to-Chrome-Web-Store side of this is covered in shipping a Manifest V3 Chrome extension to the Web Store, which is the publication gate this extension goes through once it works. The next post in this series on version-bump CI for Chrome extensions covers the discipline that keeps the extension shippable across updates.

If you are building a Chrome extension for an internal product and you want a second pair of eyes on the auth setup before the first Web Store submission, let’s talk. The auth-and-storage pattern is the kind of work that benefits from a second opinion before it ships; once shipped, the cost of changing the wire format is meaningful.