clerk-chrome-extension-patterns

Chrome Extension auth with @clerk/chrome-extension -- popup/sidepanel

INSTALLATION
npx skills add https://github.com/clerk/skills --skill clerk-chrome-extension-patterns
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$27

Method

Popup

Side Panel

syncHost (with web app)

Email + OTP

Yes

Yes

Yes

Email + Link

No

No

Yes

Email + Password

Yes

Yes

Yes

Username + Password

Yes

Yes

Yes

SMS + OTP

Yes

Yes

Yes

OAuth (Google, GitHub, etc.)

NO

NO

YES

SAML

NO

NO

YES

Passkeys

Yes

Yes

Yes

Google One Tap

No

No

Yes

Web3

No

No

Yes

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):

  • 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 type

Scope

Clears on

chrome.storage.local

Device

Uninstall or manual clear

chrome.storage.session

Session

Browser close

chrome.storage.sync

All devices

Uninstall (size-limited, 8KB)

localStorage

Popup only

Popup close -- do not use for auth

Common Pitfalls

Symptom

Cause

Fix

Redirect loop on sign-in

Missing CRX URL in ClerkProvider props

Set afterSignOutUrl, signInFallbackRedirectUrl

OAuth button not working

OAuth not supported in popup

Use syncHost to delegate to web app

Auth state stale after web app sign-in

syncHost not configured

Add syncHost prop + host_permissions

Side panel shows signed-out after web sign-in

Known limitation

User must close and reopen the side panel

Background can't get token after 60s

Session expired, no background refresh

Use createClerkClient({ background: true })

Content script can't access Clerk

Isolated world + origin restrictions

Use message passing to background service worker

Auth breaks after rebuild

CRX ID rotated

Configure stable key via .env.chrome

PLASMO_PUBLIC_ var undefined

Wrong env file

Use .env.development, not .env

Bot protection errors

Cloudflare not supported in extensions

Disable bot protection in Clerk Dashboard

Token cache not persisting

Using localStorage in popup

Use chrome.storage.local or pass tokenCache prop

Plan Requirements

Feature

Plan

Basic popup auth (email/password, OTP)

Free

Passkeys

Free

syncHost

Requires Pro (custom domain)

OAuth through syncHost

Pro + OAuth configured on web app

SAML through syncHost

Enterprise

Bot protection

N/A -- must be disabled for extensions

See Also

  • clerk-setup - Initial Clerk install
  • clerk-custom-ui - Custom flows &#x26; appearance
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card