Read this page if your page sends a `Permissions-Policy` or a `Content-Security-Policy` header. They do different jobs here: `Permissions-Policy` delegates the camera into the capture frame, while `Content-Security-Policy` governs your own page and decides, through `frame-src`, whether that frame loads at all. Getting either one wrong stops the applicant before the camera opens. There are two ways to get the camera grant wrong: an allowlist that leaves the capture origin out, and one that leaves `self` out.

If your page sends neither header, there is nothing to configure here. The SDK grants the camera to the capture frame itself, so the journey works with no header changes.

## The mistake to avoid

Two Checktiv hosts take part in an embedded journey, and they are easy to confuse:

- **The capture origin, `https://embed.us.checktiv.com`.** The verification journey runs in a frame served from this host. This is the origin the browser checks when the frame asks for the camera.
- **The CDN origin, `https://sdk.us.checktiv.com`.** The SDK script, the chunks it loads on demand, and its stylesheet are served from this host, on script-tag installs.

Grant the camera to the **capture origin**. The CDN origin is the intuitive guess, because it is the host in your `<script>` tag and the host that appears in stack traces. It is the wrong one: a browser matches a camera grant against the frame's own document origin, never against the origin the frame's scripts came from.

:::caution[Do not grant the camera to the CDN origin]
A `camera` allowlist that names the CDN host leaves the capture frame with no camera grant, so the applicant never reaches capture.

On Chrome and Edge the SDK checks the frame's own camera permission while the capture frame starts up and prints, to the browser console, the capture origin the frame needs, what the frame is currently allowed, and the full header line to add. That console message appears on every integration path and needs no wiring. It is the fastest way to find this problem.

When capture then fails, as it must on a page missing the grant, the managed paths (`Checktiv.mount()`, `mountProvisioned()`, `<ChecktivJourney>`, `<ChecktivIdv>`) report [`camera_policy_blocked`](/developers/sdks/error-reference#camera_policy_blocked) on `onEvent` instead of a device-fault code. It carries the `contact_operator` recovery, so the applicant is told to contact you rather than being sent to a second device that receives the same header. If you use the `./capture-ui` default renderer, the applicant already sees copy saying the page is not allowing the camera and that another device will not help, with no retry offered. If you build your own UI on `./capture`, the same conclusion is on the controller state as `cameraPolicyBlocked` (see [Capture](/developers/sdks/capture)).

Where that check is unavailable, the same misconfiguration still surfaces as [`sdk_load_failed`](/developers/sdks/error-reference#sdk_load_failed), which reads as a device fault and carries the `cross_device` recovery. So do not use the error code to decide whether your header is right. Confirm it with the steps under [Confirm it works](#confirm-it-works). Only the header fixes it.
:::

## Origins by region

Use the region your publishable key encodes (`ah_pk_us_*` or `ah_pk_eu_*`). See [Modes and regions](/developers/modes-and-regions).

| Host                                 | US region                         | EU region                         |
| ------------------------------------ | --------------------------------- | --------------------------------- |
| Capture origin (camera and frame)    | `https://embed.us.checktiv.com`   | `https://embed.eu.checktiv.com`   |
| API origin (session and token calls) | `https://sdk-api.us.checktiv.com` | `https://sdk-api.eu.checktiv.com` |
| CDN origin (script-tag installs)     | `https://sdk.us.checktiv.com`     | `https://sdk.eu.checktiv.com`     |

The rest of this page uses the US hosts. Swap `us` for `eu` throughout if your key is an EU key.

## Which directive needs which origin

| What your page does                    | Header directive             | Origin to allow             |
| -------------------------------------- | ---------------------------- | --------------------------- |
| Lets the journey open the camera       | `Permissions-Policy: camera` | Capture origin, plus `self` |
| Opens the verification frame           | `frame-src`                  | Capture origin              |
| Calls the session and token endpoints  | `connect-src`                | API origin                  |
| Loads the SDK and its on-demand chunks | `script-src`                 | CDN origin                  |
| Loads the SDK stylesheet               | `style-src`                  | CDN origin                  |

The `script-src` and `style-src` entries apply to script-tag installs only. If you install from npm, your bundler serves the SDK and its styles from your own origin and no CDN entry is needed.

The `script-src` entry is load-bearing for every script-tag integration, not just for pages using a particular feature. The file your `<script>` tag names does not contain the modules. Each one is a separate file on the CDN origin that the SDK fetches the first time an applicant reaches a step needing it, so `script-src` governs the whole journey rather than the opening tag alone. Allow the CDN origin there and every step loads; leave it out and the tag still loads while the first real step does not.

A blocked module does not leave a blank area, which is worth knowing before you spend time on the wrong header. Each module that renders something replaces its area with a short line asking the applicant to reload, and your `onEvent` handler receives an error carrying [`sdk_load_failed`](/developers/sdks/error-reference#sdk_load_failed). Fraud signal collection has no visible surface, so it reports through `onEvent` alone. An area that renders nothing at all is a different problem, so read [Confirm it works](#confirm-it-works) before changing this directive.

The stylesheet is what gives the capture frame its size and shape, and the custom-form step its field styling. The SDK links it from the CDN origin the first time a step renders, so there is nothing to add to your page. If `style-src` blocks it, the journey still runs and still completes, but the capture frame renders at the browser's default frame size instead of filling the space you gave it. Allow the CDN origin in `style-src` and it renders correctly.

`camera` is the only Permissions Policy feature the journey needs. It never asks for the microphone, the screen, or the location, so leave your other feature allowlists as they are.

You do not need to allow anything the capture frame loads for itself. The frame fetches its own styles, its scan engine, and its supporting files under the policy Checktiv serves on the frame. Your page's policy governs your page.

If you also embed the hosted review workspace, allow its origin in `frame-src` as well. See [Workspace reviewer](/developers/sdks/workspace-reviewer).

## Permissions-Policy

The camera grant is the entry integrations miss. At minimum:

```http
Permissions-Policy: camera=(self "https://embed.us.checktiv.com")
```

Syntax rules that are easy to get wrong:

- Wrap the allowlist in parentheses, and quote every origin. `self` is not quoted.
- List the capture origin, not your own origin alone. An allowlist of `self` on its own grants the camera to your page and denies it to the frame.
- Keep `self` in the list, even if your page never opens the camera itself. Your page can only delegate a permission it holds, so an allowlist that leaves `self` out leaves your page with no camera grant and nothing to hand to the frame. An allowlist naming the capture origin alone denies the frame just as surely as one that leaves the capture origin out.
- Keep the other directives you already send. Adding `camera` does not remove them.

The SDK already sets `allow="camera"` on the capture frame, so you never write that attribute yourself. What that attribute does depends on your header:

- If your page declares **no** `camera` directive, the attribute is enough on its own. The frame gets the camera and you need no header. This is why a page that sends no `Permissions-Policy` at all works unchanged.
- If your page **does** declare a `camera` allowlist, that allowlist must name `self` **and** the capture origin. The attribute delegates the camera to the frame, but delegation only passes on what your page already holds: it can neither add an origin your header left out nor give the frame a grant your page does not have itself.

### Next.js

```js
// next.config.js
const CHECKTIV_CAPTURE_ORIGIN = 'https://embed.us.checktiv.com';

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Permissions-Policy',
            value: `camera=(self "${CHECKTIV_CAPTURE_ORIGIN}")`,
          },
        ],
      },
    ];
  },
};
```

### Express

```js
const CHECKTIV_CAPTURE_ORIGIN = 'https://embed.us.checktiv.com';

app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', `camera=(self "${CHECKTIV_CAPTURE_ORIGIN}")`);
  next();
});
```

### A static `_headers` file

```text
/*
  Permissions-Policy: camera=(self "https://embed.us.checktiv.com")
```

## Content-Security-Policy

Configure this only if your page sends a CSP. Merge these directives into the policy you already send:

```http
Content-Security-Policy:
  script-src 'self' https://sdk.us.checktiv.com;
  style-src 'self' https://sdk.us.checktiv.com;
  connect-src 'self' https://sdk-api.us.checktiv.com;
  frame-src https://embed.us.checktiv.com;
```

:::caution[These directives fall back to default-src]
If your policy sets `default-src` and leaves out one of the directives above, the browser applies `default-src` to it. `default-src 'self'` on its own therefore blocks the capture frame. Name every directive you need, and repeat `'self'` inside each one you name, because naming a directive replaces the `default-src` value rather than extending it.
:::

:::caution[A nonce on its own does not reach the modules]
A nonce is an attribute you put on the tags in your own HTML. The SDK fetches its modules at runtime rather than by adding tags you could stamp, so nothing carries your nonce to them. A policy of the form `script-src 'nonce-...'` admits your `<script>` tag and blocks every module behind it. That is the one policy shape where the tag loads perfectly and the journey still stops at the first step.

Name the CDN origin alongside the nonce, as in the block above. If your policy already carries `'strict-dynamic'`, you need no change: it extends your page's trust to what a trusted script loads, and it makes the browser ignore origin entries in `script-src` in any case.
:::

Two things you do not need:

- **`'wasm-unsafe-eval'`.** The capture frame runs WebAssembly under the policy Checktiv serves on the frame, not under yours.
- **`style-src 'unsafe-inline'`.** A strict `style-src` is supported. The SDK adds the styles it builds at runtime through the CSS object model rather than through an inline `<style>` element, and it loads the rest as an ordinary stylesheet from the CDN origin, which is why that origin belongs in `style-src` (see the table above) and `'unsafe-inline'` does not. The SDK does set two layout-only inline `style` attributes on the capture wrapper, which a strict `style-src` drops: capture still works, and the effect is cosmetic (slightly tighter spacing above one inline alert, and the `maxWidth` mount option is ignored). Add `style-src-attr 'unsafe-inline'` if you pass `maxWidth` and need it honored.

## Headers that do not affect the journey

`X-Frame-Options` and the CSP `frame-ancestors` directive control who may frame **your** page. They place no restriction on frames your page opens, so you can keep `X-Frame-Options: DENY` and run the journey.

## Confirm it works

1. **Read the header back off the deployed page.** Headers are often set per route or per environment, so a config change that landed on the wrong route looks the same as no change at all.

   ```bash
   curl -sI https://your-page.example.com | grep -i -e permissions-policy -e content-security-policy
   ```

   If the header is absent or still the old value, fix the route pattern or the environment your header rule applies to, then re-run this command.

2. **Start a verification and read the browser console.** On Chrome and Edge the SDK checks the capture frame's own camera permission as the frame starts up, and logs a `[Checktiv]` line naming the capture origin the frame needs, what the frame is currently allowed, and the full header line to add. The browser logs its own message too, such as `Permissions policy violation: camera is not allowed in this document`. Either one means your `camera` allowlist does not reach the capture frame.

   Take the allowlist you read back in step 1 and check both halves. Either half missing denies the frame on its own, so finding one of them present is not confirmation:

   - **The bare word `self` is in the list**, unquoted. Your page can only delegate a camera grant it holds itself, so without `self` there is nothing to hand to the frame however precisely the rest of the list is written.
   - **A quoted capture origin is in the list**, the host beginning with `embed` from the table above. The CDN host beginning with `sdk` is the wrong one and grants the frame nothing.

   Add whichever half is absent, deploy, and re-run step 1. The line under [Permissions-Policy](#permissions-policy) above is the shape both halves make together. Other browsers log neither message, so a silent console here is not confirmation. Use step 1 for that.

3. **Check the Network tab for the capture frame.** Look for a request to `embed.us.checktiv.com/idv/capture`. A CSP violation in the console naming `frame-src` means the frame was blocked: add the capture origin to `frame-src`, or add the directive if your policy only sets `default-src`.

4. **If the capture area renders but is unstyled or tiny, this is not a header problem.** That is the missing capture stylesheet. Import `@checktiv/sdk-web/capture-ui/style.css` once in your app, as shown in the [Quickstart](/developers/sdks/quickstart).

If capture still does not start after step 3 passes, see [`camera_policy_blocked`](/developers/sdks/error-reference#camera_policy_blocked) and [`sdk_load_failed`](/developers/sdks/error-reference#sdk_load_failed) in the error reference for the remaining causes.

## Related pages

- [Quickstart](/developers/sdks/quickstart) - the full integration walkthrough
- [Error reference](/developers/sdks/error-reference) - every error code with its cause and recovery step
- [Modes and regions](/developers/modes-and-regions) - test mode, live mode, and choosing your region