diff --git a/docs.json b/docs.json
index 567cde9e..3e33ce15 100644
--- a/docs.json
+++ b/docs.json
@@ -266,6 +266,7 @@
"introduction/manage",
"info/projects",
"info/api-keys",
+ "info/api-key-permissions",
"info/audit-logs",
"info/network-access"
]
diff --git a/info/api-key-permissions.mdx b/info/api-key-permissions.mdx
new file mode 100644
index 00000000..4333580f
--- /dev/null
+++ b/info/api-key-permissions.mdx
@@ -0,0 +1,85 @@
+---
+title: "API Key Permissions"
+description: "Actions and resources you can grant in a scoped API key policy"
+---
+
+This page lists everything you can grant in a scoped API key's `policy`. To create a scoped key, see [Scope a key to specific resources](/info/api-keys#scope-a-key-to-specific-resources).
+
+## Resources
+
+| Resource | Matches |
+| --- | --- |
+| `organizations/{org_id}/*` | Everything in the organization. |
+| `projects/{project_id}/*` | Everything in the project. |
+| `projects/{project_id}/{kind}/{id}` | One object. `kind` is `browsers`, `profiles`, `vaults`, or `proxies`. |
+
+Resources must be inside the new key's organization, or its project for a project-scoped key. Kernel checks that every object ID exists when you create the key.
+
+## Actions
+
+### Browsers
+
+| Action | Allows |
+| --- | --- |
+| `browsers:create` | Create browsers. Grant it on a project or organization. |
+| `browsers:use` | List, inspect, connect to, and control browsers. |
+| `browsers:write` | Update browser configuration. |
+| `browsers:delete` | Delete browsers. |
+
+### Profiles
+
+| Action | Allows |
+| --- | --- |
+| `profiles:create` | Create profiles. Grant it on a project or organization. |
+| `profiles:read` | List profiles and view their metadata. |
+| `profiles:use` | Attach profiles to browsers and download them. |
+| `profiles:write` | Rename profiles and save browser state into them. |
+| `profiles:delete` | Delete profiles. |
+
+### Vaults
+
+| Action | Allows |
+| --- | --- |
+| `vaults:create` | Create vaults. Grant it on a project or organization. |
+| `vaults:read` | List vaults and view vault and item metadata. |
+| `vaults:use` | Link vaults to browsers, fill credentials, and run vault item operations. |
+| `vaults:write` | Create, update, and delete vault items. |
+| `vaults:delete` | Delete vaults and their items. |
+
+### Proxies
+
+| Action | Allows |
+| --- | --- |
+| `proxies:create` | Create proxies. Grant it on a project or organization. |
+| `proxies:read` | List proxies and view their configuration, without credentials. |
+| `proxies:use` | Select saved proxies for browsers and run health checks. |
+| `proxies:write` | Rename proxies. |
+| `proxies:delete` | Delete proxies. |
+
+### Projects and organizations
+
+| Action | Allows |
+| --- | --- |
+| `projects:read` | View project metadata and limits. |
+| `organizations:read` | View organization entitlements and limits. |
+
+## Operations that need more than one action
+
+| Operation | Requires |
+| --- | --- |
+| Create a browser | `browsers:create` and `browsers:use` on the project or organization, because the response includes connection URLs. Add `profiles:use` on its profile, `profiles:write` if it saves profile changes, `vaults:use` on each linked vault, and `proxies:use` on a saved proxy. |
+| Any operation on an existing browser | The browser action, plus `profiles:use` on its attached profile and `vaults:use` on every linked vault. If the browser saves profile changes, `profiles:write` on that profile too. |
+| Update a browser | `browsers:write` and `browsers:use`, plus `use` on any profile, vault, or proxy the update adds. |
+| Fill from a vault | `vaults:use` on the vault and `browsers:use` on the browser. |
+| Run a proxy health check | `proxies:use` and `proxies:read`. |
+
+List endpoints return only the objects the key can access, so a key without a matching grant gets an empty list.
+
+## Not available to scoped keys
+
+Scoped keys get `403` with `insufficient_scope` on:
+
+- API key management, including creating, listing, rotating, and deleting keys.
+- Browser pools.
+- Creating browsers that use saved extensions, telemetry export, or app invocations.
+- Any other endpoint not covered by the actions above.
diff --git a/info/api-keys.mdx b/info/api-keys.mdx
index 8d7ae74b..92f2daf2 100644
--- a/info/api-keys.mdx
+++ b/info/api-keys.mdx
@@ -88,6 +88,82 @@ func main() {
```
+## Scope a key to specific resources
+
+By default, an API key can do anything in its organization or project. Add a `policy` when you create a key to limit what it can do. Keys created without `policy` work exactly as before.
+
+Use scoped keys when you hand a credential to code you trust less than your backend, such as an AI agent or a short-lived job.
+
+### How policies work
+
+A policy is a list of **statements**. Each statement allows one **action** on one **resource**, and the key is denied everything no statement allows.
+
+- A **resource** is either one object, `projects/{project_id}/{kind}/{id}`, or everything in a container, `projects/{project_id}/*` or `organizations/{org_id}/*`.
+- An **action** is `{kind}:{verb}`, such as `browsers:use` or `vaults:read`.
+
+Some operations need more than one action. For example, creating a browser with a profile needs `browsers:create` and `browsers:use` on the project, plus `profiles:use` on that profile. See [API key permissions](/info/api-key-permissions) for every action and what each operation requires.
+
+### Create a scoped key
+
+This key can create and control browsers in one project, only with profile `prof_7h2kq9x4m1` or no profile, and expires in one hour:
+
+
+```typescript TypeScript
+import Kernel from '@onkernel/sdk';
+
+const kernel = new Kernel({
+ apiKey: process.env.KERNEL_API_KEY,
+});
+
+const apiKey = await kernel.apiKeys.create({
+ name: 'checkout-agent-run-4812',
+ project_id: 'proj_staging_9f3k',
+ expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ policy: {
+ statements: [
+ { action: 'browsers:create', resource: 'projects/proj_staging_9f3k/*' },
+ { action: 'browsers:use', resource: 'projects/proj_staging_9f3k/*' },
+ { action: 'profiles:use', resource: 'projects/proj_staging_9f3k/profiles/prof_7h2kq9x4m1' },
+ ],
+ },
+});
+
+console.log(apiKey.key); // Save this value now. Kernel won't show it again.
+```
+
+```python Python
+import os
+from datetime import datetime, timedelta, timezone
+
+from kernel import Kernel
+
+client = Kernel(api_key=os.environ["KERNEL_API_KEY"])
+
+api_key = client.api_keys.create(
+ name="checkout-agent-run-4812",
+ project_id="proj_staging_9f3k",
+ expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
+ policy={
+ "statements": [
+ {"action": "browsers:create", "resource": "projects/proj_staging_9f3k/*"},
+ {"action": "browsers:use", "resource": "projects/proj_staging_9f3k/*"},
+ {"action": "profiles:use", "resource": "projects/proj_staging_9f3k/profiles/prof_7h2kq9x4m1"},
+ ],
+ },
+)
+
+print(api_key.key) # Save this value now. Kernel won't show it again.
+```
+
+
+### Expiry and revocation
+
+- Use `expires_at` for an exact expiry, such as minutes from now, instead of `days_to_expire`. A scoped key can't outlive the key that creates it.
+- An expired key is rejected immediately, and a deleted key within about 10 seconds. Neither closes browser connections that are already open, so delete those browsers to cut off access.
+- Rotating a scoped key keeps its policy.
+
+Scoped keys can't manage API keys, and they get `403` on endpoints that don't support scoped access yet.
+
## Deployment API keys
When you deploy an app, Kernel mints a **deployment-scoped API key** for that deployment and injects it into the deployment (and every invocation it runs) as the `KERNEL_API_KEY` environment variable. Because the SDKs read `KERNEL_API_KEY` from the environment by default, your app can call the Kernel API as itself without you managing a key.
@@ -102,7 +178,7 @@ If you need a credential whose lifetime you control (for CI, a persistent backen
## List and inspect API keys
-List keys to audit what exists. List and retrieve responses include `masked_key`, `project_id`, `project_name`, `created_by`, and expiry metadata, but they don't include the plaintext key.
+List keys to audit what exists. List and retrieve responses include `masked_key`, `project_id`, `project_name`, `created_by`, expiry metadata, and `policy` for scoped keys, but they don't include the plaintext key.
```typescript TypeScript
@@ -228,7 +304,7 @@ func main() {
## Rotate a key
-`rotate` issues a replacement key in a single call and keeps the old key working for a short grace period, so your workload can switch over without downtime. The new key copies the rotated key's name and project scope, and—like create—Kernel returns the plaintext `key` only once.
+`rotate` issues a replacement key in a single call and keeps the old key working for a short grace period, so your workload can switch over without downtime. The new key copies the rotated key's name, project scope, and policy, and—like create—Kernel returns the plaintext `key` only once.
Two optional parameters control the timing:
@@ -308,5 +384,7 @@ To cut over immediately instead of using a grace window, pass `expire_in_days: 0
| --- | --- | --- |
| `400 Bad Request` | The name is missing, `days_to_expire` is outside `1`-`3650`, `expire_in_days` is outside `0`-`3650`, or `project_id` is empty. | Send a name, choose a valid expiry, or omit `project_id` for an org-scoped key. |
| `400 Bad Request` (rotate) | `days_to_expire` is shorter than `expire_in_days`, so the new key would expire before the old key's grace window ends. | Raise `days_to_expire` or lower `expire_in_days`. |
+| `400 Bad Request` (scoped key) | The policy has an unknown action or a resource outside the key's organization or project, or the expiry is invalid or later than your own key's. | Check the policy against [API key permissions](/info/api-key-permissions) and send one expiry field. |
| `401 Unauthorized` | Kernel couldn't authenticate the request. | Set a valid `KERNEL_API_KEY`. |
-| `404 Not Found` | The project or API key doesn't exist, or the caller can't access it. | Check the ID. If you're using a project-scoped key, you can only rotate keys in that same project. |
+| `403 Forbidden` (`insufficient_scope`) | A scoped key is missing a permission. | The message names the missing action and resource. Add it to the policy. |
+| `404 Not Found` | The project, API key, or a resource named in the policy doesn't exist, or the caller can't access it. | Check the ID. If you're using a project-scoped key, you can only rotate keys in that same project. |