Generated banner for Clerk Chrome Extension Patterns
Generated logo for Clerk Chrome Extension Patterns

Clerk Chrome Extension Patterns

ClerkOfficialCreated by clerk

  • Skill
  • Tool integration
  • Software development
  • MIT
  • Low risk
  • Has scripts

From the author

Chrome Extension auth with @clerk/chrome-extension -- popup/sidepanel setup, syncHost for OAuth/SAML via web app, createClerkClient for service workers and headless extensions, stable CRX ID. Triggers on: Chrome extension auth, Plasmo clerk, popup sign-in, syncHost, background service worker token, createClerkClient, headless extension.
— the description by clerk

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This guide provides essential patterns for integrating Clerk authentication into Chrome extensions. It covers handling authentication in popups, side panels, background service workers, and headless environments. It addresses critical constraints such as origin restrictions, session management, and the necessity of stable CRX IDs for production deployments.

What it does

  • Defines patterns for popup and side panel authentication flows.
  • Explains how to sync authentication state between a web app and an extension using syncHost.
  • Provides methods for using Clerk in background service workers and vanilla JS environments.
  • Outlines secure message-passing techniques for content scripts.
  • Details configuration requirements for stable extension IDs and manifest permissions.
  • Lists supported and unsupported authentication methods for different extension contexts.

When to use it

  • When building a Chrome extension that requires user authentication.
  • When you need to share authentication state between your web application and your extension.
  • When implementing authentication logic within background service workers or content scripts.
  • When troubleshooting common extension auth issues like redirect loops or stale sessions.

Advantages

  • Ensures secure and reliable authentication across different extension contexts.
  • Prevents common pitfalls like session expiration and origin-based access blocks.
  • Provides clear guidance on handling complex flows like OAuth and SAML in extensions.
  • Offers standardized patterns for Plasmo-based development.

Usage examples

“How do I keep my extension's session active in the background?”
Use createClerkClient with the background: true flag to ensure the session token is refreshed continuously even when the UI is closed.
“Can I use OAuth in my Chrome extension popup?”
No, OAuth is not supported in popups. You must use syncHost to delegate the authentication process to your web application.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/clerk/clerk-chrome-extension-patterns/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip clerk-chrome-extension-patterns.zip -d .agents/skills

Documentation by clerk

Chrome Extension Patterns

CRITICAL RULES

  1. OAuth (Google, GitHub, etc.) and SAML are NOT supported in popups or side panels -- use syncHost to delegate auth to your web app

  2. Email links (magic links) don't work in popups -- the popup closes when the user clicks outside, resetting sign-in state

  3. Side panels don't auto-refresh auth state -- users must close and reopen the side panel after signing in via the web app

  4. Service workers and content scripts have NO access to Clerk React hooks -- use createClerkClient() or message passing

  5. Extension URLs use chrome-extension:// not http:// -- all redirect URLs must use chrome.runtime.getURL('.')

  6. Without a stable CRX ID, every rebuild breaks auth -- configure key in manifest BEFORE deploying

  7. Content scripts cannot use Clerk directly due to origin restrictions -- Clerk enforces strict allowed origins

  8. Bot protection must be DISABLED in Clerk Dashboard -- Cloudflare bot detection is not supported in extension environments

Authentication Options

MethodPopupSide PanelsyncHost (with web app)
Email + OTPYesYesYes
Email + LinkNoNoYes
Email + PasswordYesYesYes
Username + PasswordYesYesYes
SMS + OTPYesYesYes
OAuth (Google, GitHub, etc.)NONOYES
SAMLNONOYES
PasskeysYesYesYes
Google One TapNoNoYes
Web3NoNoYes

Quick Start (Plasmo)

npx create-plasmo --with-tailwindcss --with-src my-extension
cd my-extension
npm install @clerk/chrome-extension

Enable Native API in Clerk Dashboard under Native applications. Required for all extension integrations.

.env.development:

PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_FRONTEND_API=https://your-app.clerk.accounts.dev

src/popup.tsx:

import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/chrome-extension'

const PUBLISHABLE_KEY = process.env.PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY
const EXTENSION_URL = chrome.runtime.getURL('.')

if (!PUBLISHABLE_KEY) {
  throw new Error('Missing PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY')
}

function IndexPopup() {
  return (
    <ClerkProvider
      publishableKey={PUBLISHABLE_KEY}
      afterSignOutUrl={`${EXTENSION_URL}/popup.html`}
      signInFallbackRedirectUrl={`${EXTENSION_URL}/popup.html`}
      signUpFallbackRedirectUrl={`${EXTENSION_URL}/popup.html`}
    >
      <Show when="signed-out">
        <SignInButton mode="modal" />
        <SignUpButton mode="modal" />
      </Show>
      <Show when="signed-in">
        <UserButton />
      </Show>
    </ClerkProvider>
  )
}

export default IndexPopup

Use mode="modal" for SignInButton -- navigating to a separate page breaks the popup flow.

syncHost -- Sync Auth with Web App

Use this when you need OAuth, SAML, or want the extension to reflect sign-in from your web app.

How it works: The extension reads the Clerk session cookie from your web app's domain via host_permissions.

Step 1 -- Environment variables:

.env.development:

PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_FRONTEND_API=https://your-app.clerk.accounts.dev
PLASMO_PUBLIC_CLERK_SYNC_HOST=http://localhost

.env.production:

PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_FRONTEND_API=https://clerk.your-domain.com
PLASMO_PUBLIC_CLERK_SYNC_HOST=https://clerk.your-domain.com

Step 2 -- Add syncHost prop:

const SYNC_HOST = process.env.PLASMO_PUBLIC_CLERK_SYNC_HOST

<ClerkProvider
  publishableKey={PUBLISHABLE_KEY}
  syncHost={SYNC_HOST}
  afterSignOutUrl="/"
  routerPush={(to) => navigate(to)}
  routerReplace={(to) => navigate(to, { replace: true })}
>

Step 3 -- Configure host_permissions in package.json:

{
  "manifest": {
    "key": "$CRX_PUBLIC_KEY",
    "permissions": ["cookies", "storage"],
    "host_permissions": [
      "$PLASMO_PUBLIC_CLERK_SYNC_HOST/*",
      "$CLERK_FRONTEND_API/*"
    ]
  }
}

Step 4 -- Add extension ID to web app's allowed origins via Clerk API:

curl -X PATCH https://api.clerk.com/v1/instance \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "Content-type: application/json" \
  -d '{"allowed_origins": ["chrome-extension://YOUR_EXTENSION_ID"]}'

Hide unsupported auth methods in popup when using syncHost:

<SignIn
  appearance={{
    elements: {
      socialButtonsRoot: 'plasmo-hidden',
      dividerRow: 'plasmo-hidden',
    },
  }}
/>

Full guide: references/sync-host.md

createClerkClient() for Vanilla JS / Service Workers

Import from @clerk/chrome-extension/client (not @clerk/chrome-extension).

Background service worker (src/background/index.ts):

import { createClerkClient } from '@clerk/chrome-extension/client'

const publishableKey = process.env.PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY

async function getToken(): Promise<string | null> {
  const clerk = await createClerkClient({
    publishableKey,
    background: true,
  })
  if (!clerk.session) return null
  return await clerk.session.getToken()
}

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  getToken()
    .then((token) => sendResponse({ token }))
    .catch((error) => {
      console.error('[Background] Error:', JSON.stringify(error))
      sendResponse({ token: null })
    })
  return true
})

The background: true flag keeps sessions fresh even when popup/sidepanel is closed. Without it, tokens expire after 60 seconds.

Popup with vanilla JS (src/popup.ts):

import { createClerkClient } from '@clerk/chrome-extension/client'

const EXTENSION_URL = chrome.runtime.getURL('.')
const POPUP_URL = `${EXTENSION_URL}popup.html`

const clerk = createClerkClient({ publishableKey })

clerk.load({
  afterSignOutUrl: POPUP_URL,
  signInForceRedirectUrl: POPUP_URL,
  signUpForceRedirectUrl: POPUP_URL,
  allowedRedirectProtocols: ['chrome-extension:'],
}).then(() => {
  clerk.addListener(render)
  render()
})

Full guide: references/create-clerk-client.md

Headless Extension (no popup, no side panel)

For extensions that run entirely in the background and sync with a web app.

Uses syncHost + createClerkClient with background: true to read auth state from the web app's cookies.

import { createClerkClient } from '@clerk/chrome-extension/client'

const publishableKey = process.env.PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY
const syncHost = process.env.PLASMO_PUBLIC_CLERK_SYNC_HOST

async function getAuthenticatedUser() {
  const clerk = await createClerkClient({
    publishableKey,
    syncHost,
    background: true,
  })
  return clerk.user
}

Requires host_permissions for the sync host domain in package.json.

Full guide: references/headless-extension.md

Content Scripts

Content scripts run in an isolated JavaScript world injected into web pages. Clerk cannot be used directly -- origin restrictions prevent it.

Use message passing to request auth state from the background service worker:

// content.ts
async function getToken(): Promise<string | null> {
  return new Promise((resolve) => {
    chrome.runtime.sendMessage({ type: 'GET_TOKEN' }, (response) => {
      resolve(response?.token ?? null)
    })
  })
}

async function main() {
  const token = await getToken()
  if (!token) return
  // use token for authenticated API calls
}

main()

Full guide: references/content-scripts.md

Stable CRX ID

Without a pinned key, Chrome derives the CRX ID from a random key at build time. This rotates every rebuild, breaking allowed origins.

Option A -- Plasmo Itero (recommended):

  1. Visit Plasmo Itero Generate Keypairs

  2. Click "Generate KeyPairs" -- save Private Key securely, copy Public Key and CRX ID

Option B -- OpenSSL:

openssl genrsa -out key.pem 2048
# Use Plasmo Itero to convert or extract the public key in correct format

.env.chrome:

CRX_PUBLIC_KEY="<PUBLIC KEY from Itero>"

package.json:

{
  "manifest": {
    "key": "$CRX_PUBLIC_KEY",
    "permissions": ["cookies", "storage"],
    "host_permissions": [
      "http://localhost/*",
      "$CLERK_FRONTEND_API/*"
    ]
  }
}

Add chrome-extension://YOUR_STABLE_CRX_ID to Clerk Dashboard > Allowed Origins.

Token Cache (persist across popup closes)

const tokenCache = {
  async getToken(key: string) {
    const result = await chrome.storage.local.get(key)
    return result[key] ?? null
  },
  async saveToken(key: string, token: string) {
    await chrome.storage.local.set({ [key]: token })
  },
  async clearToken(key: string) {
    await chrome.storage.local.remove(key)
  },
}

<ClerkProvider publishableKey={PUBLISHABLE_KEY} tokenCache={tokenCache}>
Storage typeScopeClears on
chrome.storage.localDeviceUninstall or manual clear
chrome.storage.sessionSessionBrowser close
chrome.storage.syncAll devicesUninstall (size-limited, 8KB)
localStoragePopup onlyPopup close -- do not use for auth

Common Pitfalls

SymptomCauseFix
Redirect loop on sign-inMissing CRX URL in ClerkProvider propsSet afterSignOutUrl, signInFallbackRedirectUrl
OAuth button not workingOAuth not supported in popupUse syncHost to delegate to web app
Auth state stale after web app sign-insyncHost not configuredAdd syncHost prop + host_permissions
Side panel shows signed-out after web sign-inKnown limitationUser must close and reopen the side panel
Background can't get token after 60sSession expired, no background refreshUse createClerkClient({ background: true })
Content script can't access ClerkIsolated world + origin restrictionsUse message passing to background service worker
Auth breaks after rebuildCRX ID rotatedConfigure stable key via .env.chrome
PLASMO_PUBLIC_ var undefinedWrong env fileUse .env.development, not .env
Bot protection errorsCloudflare not supported in extensionsDisable bot protection in Clerk Dashboard
Token cache not persistingUsing localStorage in popupUse chrome.storage.local or pass tokenCache prop

Plan Requirements

FeaturePlan
Basic popup auth (email/password, OTP)Free
PasskeysFree
syncHostRequires Pro (custom domain)
OAuth through syncHostPro + OAuth configured on web app
SAML through syncHostEnterprise
Bot protectionN/A -- must be disabled for extensions

See Also

  • clerk-setup - Initial Clerk install

  • clerk-custom-ui - Custom flows & appearance

Files

references/create-clerk-client.md

createClerkClient() -- Vanilla JS and Service Workers

When to Use

Use createClerkClient() when:

  • Your extension doesn't use React

  • You need Clerk in a background service worker

  • You need Clerk in a content script context (via message passing from background)

  • You want to keep sessions fresh without a visible popup

Import from @clerk/chrome-extension/client, NOT from @clerk/chrome-extension.

Background Service Worker

The key option is background: true. This tells Clerk to refresh the session token continuously, even when no popup or side panel is open. Without it, tokens expire after 60 seconds of the UI being closed.

src/background/index.ts:

import { createClerkClient } from '@clerk/chrome-extension/client'

const publishableKey = process.env.PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY

if (!publishableKey) {
  throw new Error('Missing PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY')
}

async function getToken(): Promise<string | null> {
  const clerk = await createClerkClient({
    publishableKey,
    background: true,
  })

  if (!clerk.session) return null

  return await clerk.session.getToken()
}

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  getToken()
    .then((token) => sendResponse({ token }))
    .catch((error) => {
      console.error('[Background service worker] Error:', JSON.stringify(error))
      sendResponse({ token: null })
    })
  return true
})

The listener MUST return true to keep the message channel open for the async sendResponse call.

Requesting the Token from a Tab or Content Script

// tabs/my-tab.tsx or a content script
async function getTokenFromBackground(): Promise<string | null> {
  return new Promise((resolve) => {
    chrome.runtime.sendMessage({ type: 'GET_TOKEN' }, (response) => {
      resolve(response?.token ?? null)
    })
  })
}

async function makeAuthenticatedRequest() {
  const token = await getTokenFromBackground()
  if (!token) {
    console.warn('User not signed in')
    return
  }

  const res = await fetch('https://api.example.com/me', {
    headers: { Authorization: `Bearer ${token}` },
  })

  return res.json()
}

Vanilla JS Popup (no React)

For popups or side panels that use plain TypeScript instead of React:

src/popup.ts:

import { createClerkClient } from '@clerk/chrome-extension/client'

const publishableKey = process.env.CLERK_PUBLISHABLE_KEY
const EXTENSION_URL = chrome.runtime.getURL('.')
const POPUP_URL = `${EXTENSION_URL}popup.html`

const clerk = createClerkClient({ publishableKey })
const contentEl = document.getElementById('content') as HTMLDivElement

function render() {
  const email = clerk.user?.primaryEmailAddress?.emailAddress
  contentEl.textContent = email ?? 'Not signed in'
}

clerk.load({
  afterSignOutUrl: POPUP_URL,
  signInForceRedirectUrl: POPUP_URL,
  signUpForceRedirectUrl: POPUP_URL,
  allowedRedirectProtocols: ['chrome-extension:'],
}).then(() => {
  clerk.addListener(render)
  render()
})

allowedRedirectProtocols: ['chrome-extension:'] is required to allow redirects to chrome-extension:// URLs.

createClerkClient() Options

OptionTypeDescription
publishableKeystringRequired. Your Clerk publishable key.
backgroundbooleanSet true in service workers to keep sessions fresh.
syncHoststringThe web app domain to sync auth from (headless extensions).

Making Authenticated API Calls from Background

async function callMyAPI() {
  const clerk = await createClerkClient({ publishableKey, background: true })

  if (!clerk.session) return

  const token = await clerk.session.getToken()

  const res = await fetch('https://api.yourapp.com/data', {
    headers: { Authorization: `Bearer ${token}` },
  })

  return res.json()
}

Docs

createClerkClient() reference

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Declares allowed tools, Needs network access, Needs Node.js

The author says: Requires PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY (Plasmo prefix for public env vars) and CLERK_FRONTEND_API.

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
clerk
Publisher
Clerk (Official)
Source
clerk/skills · skills/clerk-chrome-extension-patterns · commit c5d2c0d
Licence
MIT License (redistribution allowed)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by clerk.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download clerk-chrome-extension-patterns.zip