From features
Provides Clerk authentication patterns for Chrome extensions including popup/sidepanel setup, syncHost for OAuth/SAML via web app, createClerkClient for service workers, and headless extensions.
npx claudepluginhub clerk/skills --plugin mobileThis skill is limited to using the following tools:
1. OAuth (Google, GitHub, etc.) and SAML are NOT supported in popups or side panels -- use `syncHost` to delegate auth to your web app
Comprehensive guide for building Chrome extensions with Manifest V3. Use this skill whenever the user mentions Chrome extension, browser extension, manifest.json, content script, service worker (in extension context), popup, side panel, chrome.runtime, chrome.tabs, chrome.storage, chrome.scripting, background script, MV3, Manifest V3, or any Chrome extension API. Also trigger when the user wants to inject scripts into web pages, communicate between page and background, bypass CSP from a content script, build an RPC layer over chrome messaging, or publish to the Chrome Web Store. Covers both new extension projects and adding features to existing ones. Do NOT use for framework-specific questions.
Builds Chrome Extensions with Manifest V3, covering service workers, content scripts, popup pages, message passing, storage, permissions, and debugging cross-context communication.
Builds Manifest V3 browser extensions emphasizing service worker persistence (alarms, offscreen API, storage), side panels, security, and cross-browser support.
Share bugs, ideas, or general feedback.
syncHost to delegate auth to your web appcreateClerkClient() or message passingchrome-extension:// not http:// -- all redirect URLs must use chrome.runtime.getURL('.')key in manifest BEFORE deploying| 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 |
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.
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
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
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 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
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):
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.
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 |
| 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 |
| 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 |
clerk-setup - Initial Clerk installclerk-custom-ui - Custom flows & appearance