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 & appearance