Skills로 돌아가기
clerk/skills실행 전 동작 확인

SKILL DETAIL

clerk-chrome-extension-patterns

clerk/skills/clerk-chrome-extension-patterns

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

설치 수 · 95출처 보기

Installation

npx skills add https://github.com/clerk/skills --skill clerk-chrome-extension-patterns

스킬 파일

SKILL.md

최근 동기화 · 2026. 8. 30.

evals/evals.json
{
  "skill_name": "clerk-chrome-extension-patterns",
  "evals": [
    {
      "id": 1,
      "prompt": "i need to wrap my chrome extension popup in ClerkProvider from @clerk/chrome-extension. what props does it need for redirect URLs to work inside a chrome extension?",
      "expected_output": "ClerkProvider with publishableKey, redirect URLs using chrome.runtime.getURL",
      "scaffold": "chrome-ext-basic-auth",
      "expectations": [
        "Imports ClerkProvider from @clerk/chrome-extension (not @clerk/react or @clerk/nextjs)",
        "Passes publishableKey prop to ClerkProvider",
        "Uses chrome.runtime.getURL for redirect URL props (afterSignOutUrl, signInFallbackRedirectUrl, or similar)",
        "Uses SignInButton or SignUpButton for unauthenticated state",
        "Uses UserButton or user profile component for authenticated state"
      ]
    },
    {
      "id": 2,
      "prompt": "my background service worker needs to make authenticated API calls to my server. how can it access the current user's session token?",
      "expected_output": "Use createClerkClient from @clerk/chrome-extension/client with background: true to get a fresh session token",
      "scaffold": "chrome-ext-basic-auth",
      "expectations": [
        "Imports createClerkClient from @clerk/chrome-extension/client (not @clerk/chrome-extension)",
        "Calls createClerkClient with background: true option",
        "Checks clerk.session before calling getToken()",
        "Returns true from the chrome.runtime.onMessage listener to keep connection open for async sendResponse",
        "Explains that background: true keeps the session fresh when popup is closed"
      ]
    },
    {
      "id": 3,
      "prompt": "my content script needs to check if the user is signed in before injecting a UI overlay into the page. how do i access clerk auth state from a content script?",
      "expected_output": "Content script uses message passing to background service worker which uses createClerkClient to get auth state",
      "scaffold": "chrome-ext-basic-auth",
      "expectations": [
        "Explains that Clerk cannot be used directly in content scripts due to origin restrictions",
        "Uses chrome.runtime.sendMessage to request auth state from background service worker",
        "Background script uses createClerkClient from @clerk/chrome-extension/client",
        "Shows how to conditionally inject UI based on the response",
        "Handles the async nature of message passing with return true in background listener"
      ]
    },
    {
      "id": 4,
      "prompt": "my chrome extension needs to let users sign in with google oauth. how do i set that up?",
      "expected_output": "OAuth is not supported directly in popup or side panel -- must use syncHost to delegate to a web app",
      "scaffold": "chrome-ext-basic-auth",
      "expectations": [
        "States clearly that OAuth is NOT supported in the popup or side panel",
        "Explains that OAuth requires a redirect from the Identity Provider which Chrome extensions do not support",
        "Introduces the syncHost approach to delegate auth to a companion web app",
        "Mentions adding PLASMO_PUBLIC_CLERK_SYNC_HOST env var",
        "Mentions adding syncHost prop to ClerkProvider"
      ]
    },
    {
      "id": 5,
      "prompt": "i want my extension to automatically know when the user signs in on my web app without them having to interact with the popup. how?",
      "expected_output": "Use syncHost prop + host_permissions + add extension ID to allowed_origins via Clerk API",
      "scaffold": "chrome-ext-basic-auth",
      "expectations": [
        "Introduces the syncHost feature to sync auth state from web app to extension",
        "Shows PLASMO_PUBLIC_CLERK_SYNC_HOST env var setup for dev and prod",
        "Shows adding syncHost prop to ClerkProvider",
        "Shows host_permissions configuration in package.json manifest",
        "Shows the curl command to add extension ID to allowed_origins via Clerk API"
      ]
    },
    {
      "id": 6,
      "prompt": "every time i rebuild my chrome extension, the extension ID changes and clerk auth breaks because the redirect URLs don't match. how do i fix this?",
      "expected_output": "Pin the extension ID by providing a consistent key in the manifest, then update Clerk Dashboard with the stable chrome-extension:// URL",
      "scaffold": "chrome-ext-basic-auth",
      "expectations": [
        "Explains that the extension ID changes because Chrome derives it from the key field",
        "Shows how to pin the extension ID by providing a consistent key via .env.chrome and package.json",
        "Mentions the CRX_PUBLIC_KEY env var and manifest key field",
        "Mentions updating the Clerk Dashboard or allowed_origins with the stable chrome-extension:// URL",
        "Notes that Plasmo Itero can generate the keypair"
      ]
    }
  ]
}
references/content-scripts.md
# Content Scripts

## Constraint

Content scripts run in an isolated JavaScript world injected into web pages. They cannot:
- Use Clerk React hooks
- Call Clerk APIs directly (Clerk enforces strict origin restrictions -- content scripts could run on any domain)
- Access the extension's React context

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

## Pattern: Request Token from Background

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

async function isSignedIn(): Promise<boolean> {
  const token = await getToken()
  return token !== null
}

async function injectUI() {
  const signedIn = await isSignedIn()

  if (!signedIn) {
    console.log('User not signed in, skipping injection')
    return
  }

  const overlay = document.createElement('div')
  overlay.id = 'my-extension-overlay'
  document.body.appendChild(overlay)
}

injectUI()
```

`src/background/index.ts`:
```typescript
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) => {
  if (request.type === 'GET_TOKEN') {
    getToken()
      .then((token) => sendResponse({ token }))
      .catch(() => sendResponse({ token: null }))
    return true
  }
})
```

## Pattern: Authenticated Fetch from Content Script

```typescript
// content.ts
async function fetchUserData() {
  const token = await getToken()
  if (!token) return null

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

  return res.json()
}
```

## Manifest Permissions

`package.json` (Plasmo):
```json
{
  "manifest": {
    "permissions": ["storage", "tabs"],
    "host_permissions": ["<all_urls>"]
  }
}
```

For content scripts on specific domains only:
```json
{
  "manifest": {
    "permissions": ["storage"],
    "host_permissions": ["https://specific-site.com/*"]
  }
}
```

## Content Script Registration (Plasmo)

A file named `content.ts` or `content.tsx` at the project root is auto-registered as a content script matching all URLs.

For multiple content scripts with different match patterns, use `package.json`:
```json
{
  "manifest": {
    "content_scripts": [
      {
        "matches": ["https://specific-site.com/*"],
        "js": ["content.js"]
      }
    ]
  }
}
```

## Why Clerk Can't Be Used Directly in Content Scripts

Clerk enforces strict allowed origins for API requests. A content script can be injected into any domain (e.g., `https://github.com`, `https://google.com`). There is no way to add all possible domains to Clerk's allowed origins, so direct Clerk usage in content scripts is blocked by design.

[Docs](https://clerk.com/docs/chrome-extension/getting-started/quickstart)
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`:
```typescript
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

```typescript
// 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`:
```typescript
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

| Option | Type | Description |
|--------|------|-------------|
| `publishableKey` | `string` | Required. Your Clerk publishable key. |
| `background` | `boolean` | Set `true` in service workers to keep sessions fresh. |
| `syncHost` | `string` | The web app domain to sync auth from (headless extensions). |

## Making Authenticated API Calls from Background

```typescript
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](https://clerk.com/docs/reference/chrome-extension/create-clerk-client)
references/headless-extension.md
# Headless Extension (no popup, no side panel)

## Use Case

An extension that runs entirely in the background -- no UI, no popup, no side panel. It syncs auth state from a companion web app and acts on behalf of the signed-in user automatically.

Examples:
- Auto-fill tools that activate when the user visits certain pages
- Extensions that sync data in the background when the user is signed in on the web app
- Developer tools that call your API without user interaction

## Requirements

- The user signs in via your web app (not the extension)
- The extension reads auth state from the web app's session cookie
- `syncHost` + `createClerkClient({ background: true })` combination

## Background Service Worker

`src/background/index.ts`:
```typescript
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

if (!publishableKey || !syncHost) {
  throw new Error('Missing publishable key or sync host')
}

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

  return clerk.user
}

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

  if (!clerk.session) return null

  return await clerk.session.getToken()
}

chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
  if (changeInfo.status !== 'complete') return

  const token = await getSessionToken()
  if (!token) return

  await fetch('https://api.yourapp.com/page-visit', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ url: tab.url }),
  })
})
```

## 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
```

## Manifest Configuration

`package.json`:
```json
{
  "manifest": {
    "key": "$CRX_PUBLIC_KEY",
    "permissions": ["cookies", "storage", "tabs"],
    "host_permissions": [
      "$PLASMO_PUBLIC_CLERK_SYNC_HOST/*",
      "$CLERK_FRONTEND_API/*"
    ]
  }
}
```

`host_permissions` for the sync host domain is what allows the extension to read the Clerk session cookie from the web app.

## Register Extension in Clerk

The extension ID must be in your web app instance's allowed origins:

```bash
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"]}'
```

## Key Difference from Popup + syncHost

In a popup extension with `syncHost`, the user can also sign in directly via the popup (email/password, OTP). In a headless extension, there is no UI at all -- the user MUST sign in via the web app. The extension only reads auth state.

## Debugging

To verify auth state is syncing:

```typescript
const clerk = await createClerkClient({ publishableKey, syncHost, background: true })
console.log('User:', clerk.user?.emailAddresses[0]?.emailAddress ?? 'Not signed in')
console.log('Session:', clerk.session?.id ?? 'No session')
```

If `user` is null despite being signed in on the web app, check:
1. `host_permissions` includes the sync host domain
2. The extension ID is in Clerk's allowed origins
3. The `syncHost` value matches the Clerk Frontend API URL (not the web app's main domain)
references/sync-host.md
# syncHost -- Sync Auth with Web App

## When to Use

Use `syncHost` when you need:
- OAuth (Google, GitHub, etc.)
- SAML
- Email magic links
- The extension to reflect auth state from your web app without the user signing in again

Without `syncHost`, the extension popup can only do email/password, OTP, and passkeys.

## How It Works

The extension reads Clerk's session cookie from your web app's domain using `host_permissions`. The `syncHost` prop tells `ClerkProvider` which domain to sync from.

## Step 1 -- Environment Variables

Use separate files for dev vs prod so Plasmo passes the right values to each build.

`.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
```

The production value of `PLASMO_PUBLIC_CLERK_SYNC_HOST` is the domain your Clerk Frontend API runs on (e.g., `https://clerk.your-domain.com`), not your app's main domain.

## Step 2 -- ClerkProvider with syncHost

```tsx
import { ClerkProvider, Show, UserButton } from '@clerk/chrome-extension'
import { Link, Outlet, useNavigate } from 'react-router-dom'

const PUBLISHABLE_KEY = process.env.PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY
const SYNC_HOST = process.env.PLASMO_PUBLIC_CLERK_SYNC_HOST

if (!PUBLISHABLE_KEY || !SYNC_HOST) {
  throw new Error('Missing PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY or PLASMO_PUBLIC_CLERK_SYNC_HOST')
}

export function RootLayout() {
  const navigate = useNavigate()

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

## Step 3 -- Manifest host_permissions

In `package.json`, configure `host_permissions` to grant the extension access to the sync host domain:

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

Plasmo interpolates env vars in `package.json` at build time. In dev this resolves to `http://localhost/*`.

## Step 4 -- Register Extension ID in Clerk

Clerk must explicitly allow requests from the extension's origin. Run this once per environment:

```bash
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"]}'
```

Replace `YOUR_SECRET_KEY` with your Clerk Secret Key (`sk_test_...` or `sk_live_...`) and `YOUR_EXTENSION_ID` with your stable CRX ID.

If your extension ID changes (unstable key), you must re-run this command. Configure a stable CRX ID to avoid repeating this step.

## Hide Unsupported Auth Methods in Popup

When using `syncHost`, your web app may have OAuth enabled but the popup itself can't do OAuth flows. Hide those buttons in the popup:

```tsx
import { SignIn, SignUp } from '@clerk/chrome-extension'

function SignInPage() {
  return (
    <SignIn
      appearance={{
        elements: {
          socialButtonsRoot: 'plasmo-hidden',
          dividerRow: 'plasmo-hidden',
        },
      }}
    />
  )
}

function SignUpPage() {
  return (
    <SignUp
      appearance={{
        elements: {
          socialButtonsRoot: 'plasmo-hidden',
          dividerRow: 'plasmo-hidden',
        },
      }}
    />
  )
}
```

This way the popup shows only the methods it supports (email/password, OTP) while the web app exposes all methods including OAuth.

## Side Panel Limitation

`syncHost` does not fully support side panels. If a user signs in via the web app, the side panel will not automatically update its auth state. The user must close and reopen the side panel to reflect the new auth status. This is a known limitation of the SDK.

## Docs

[Sync auth status guide](https://clerk.com/docs/guides/sessions/sync-host)
SKILL.md
---
name: clerk-chrome-extension-patterns
description: '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.'
license: MIT
allowed-tools: WebFetch
compatibility: Requires PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY (Plasmo prefix for public env vars) and CLERK_FRONTEND_API.
metadata:
  author: clerk
  version: 2.0.0
  references:
  - references/sync-host.md
  - references/create-clerk-client.md
  - references/content-scripts.md
  - references/headless-extension.md
---

# 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

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

```bash
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`:
```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:**

```tsx
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`:**

```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:**

```bash
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:**

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

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

```typescript
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.

```typescript
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:

```typescript
// 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](https://itero.plasmo.com/ext/generate-keypairs)
2. Click "Generate KeyPairs" -- save Private Key securely, copy Public Key and CRX ID

**Option B -- OpenSSL:**
```bash
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`:**
```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)

```tsx
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
templates/chrome-ext-basic-auth/package.json
{
  "name": "clerk-chrome-extension",
  "displayName": "Clerk Chrome Extension",
  "scripts": { "dev": "plasmo dev", "build": "plasmo build" },
  "dependencies": {
    "@clerk/chrome-extension": "latest",
    "plasmo": "latest",
    "react": "latest",
    "react-dom": "latest"
  },
  "manifest": {
    "key": "$CRX_PUBLIC_KEY",
    "permissions": ["cookies", "storage"],
    "host_permissions": ["http://localhost/*", "$CLERK_FRONTEND_API/*"]
  }
}
templates/chrome-ext-basic-auth/src/popup.tsx
import {
  ClerkProvider,
  Show,
  SignInButton,
  SignUpButton,
  UserButton,
} from '@clerk/chrome-extension'

import '~style.css'

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

if (!PUBLISHABLE_KEY) {
  throw new Error('Please add the PLASMO_PUBLIC_CLERK_PUBLISHABLE_KEY to the .env.development file')
}

function IndexPopup() {
  return (
    <ClerkProvider
      publishableKey={PUBLISHABLE_KEY}
      afterSignOutUrl={`${EXTENSION_URL}/popup.html`}
      signInFallbackRedirectUrl={`${EXTENSION_URL}/popup.html`}
      signUpFallbackRedirectUrl={`${EXTENSION_URL}/popup.html`}
    >
      <div className="plasmo-flex plasmo-items-center plasmo-justify-center plasmo-h-[600px] plasmo-w-[800px] plasmo-flex-col">
        <header className="plasmo-w-full">
          <Show when="signed-out">
            <SignInButton mode="modal" />
            <SignUpButton mode="modal" />
          </Show>
          <Show when="signed-in">
            <UserButton />
          </Show>
        </header>
      </div>
    </ClerkProvider>
  )
}

export default IndexPopup
templates/chrome-ext-basic-auth/tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM"]
  }
}
clerk-chrome-extension-patterns · 인기 상승 중인 Agent Skills | Mengbi