# How to give users API keys for a SvelteKit app

Open your app's data as a REST API and let users create and revoke their own keys. vela enable api serves PocketBase's API under /api; vela enable api-keys adds the keys page, hashed secrets and Bearer-token access, with collection rules still in charge.

Pattern: [Enable API keys](https://velastack.dev/patterns/enable-api-keys) · Docs: https://docs.velastack.dev/enable/api-keys

At some point a user wants to reach your app from somewhere that is not a browser: a script, a CI job, a spreadsheet, another product. That means an API and a way to authenticate to it that is not a session cookie. In a vela app the API already exists, because PocketBase has one for every collection; it is just not exposed. Two commands open it up and let signed-in users mint keys for it.

## Prerequisites

A vela project with a backend and [authentication](https://velastack.dev/tutorials/sveltekit-login-and-signup) enabled. Something to fetch helps: a [scaffolded](https://velastack.dev/tutorials/sveltekit-crud-scaffold) `todos` collection is used below.

## Step 1: expose the API

```sh
$ vela enable api
✔ Created 1 file, modified 1
  src/routes/api/server.test.ts
  src/hooks.server.ts
```

One option in the server hook. PocketBase's endpoints are now served under `/api` next to your own SvelteKit routes:

**src/hooks.server.ts**

```ts
import { env } from "$env/dynamic/private";
import { handlePocketbase } from "@velastack/pocketbase";

export const handle = handlePocketbase({
  pocketbaseUrl: env.POCKETBASE_URL,
  superuserEmail: env.POCKETBASE_SUPERUSER_EMAIL,
  superuserPassword: env.POCKETBASE_SUPERUSER_PASSWORD,
  // [!code highlight:1]
  api: { enabled: true },
});
```

The collection rules you already rely on in the app apply here too. A collection whose list rule is `owner = @request.auth.id` returns nothing to an anonymous request. The test just checks the API answers:

**src/routes/api/server.test.ts**

```ts
import { describe, expect, it } from "vitest";

describe("GET /api/health", () => {
  it("should return a 200 status code", async (context) => {
    const response = await context.request.get("/api/health");
    expect(response.status).toBe(200);
  });
});
```

## Step 2: API keys

```sh
$ vela enable api-keys
✔ Created 12 files, modified 2
✔ Added collection api_keys
  src/routes/(app)/api-keys/+layout.svelte
  src/routes/(app)/api-keys/new/+page.svelte
  src/routes/(app)/api-keys/new/+page.server.ts
  src/routes/(app)/api-keys/new/create-api-key.ts
  src/routes/(app)/api-keys/[id]/+page.server.ts
  src/lib/schemas/apiKey.ts
  src/hooks.server.ts
```

### How a key works

A key is `<id>.<secret>`. The secret is random, hashed with SHA-256 before it is stored, and never written anywhere else:

**src/routes/(app)/api-keys/new/create-api-key.ts**

```ts
import {
  generateApiKeySecret,
  hashApiKey,
} from "@velastack/pocketbase/api-key";

export const createApiKey = async (
  pb: App.Locals["pb"],
  userId: string | undefined,
  label: string,
) => {
  const keySecret = generateApiKeySecret();
  const apiKey = await pb
    .collection("api_keys")
    .create({ key_hash: hashApiKey(keySecret), user: userId, label });

  return `${apiKey.id}.${keySecret}`;
};
```

The hook turns that on. A request to `/api/...` with `Authorization: Bearer <key>` looks up the id, verifies the hash and runs as the owning user:

**src/hooks.server.ts**

```ts
import { env } from "$env/dynamic/private";
import { handlePocketbase } from "@velastack/pocketbase";

export const handle = handlePocketbase({
  pocketbaseUrl: env.POCKETBASE_URL,
  superuserEmail: env.POCKETBASE_SUPERUSER_EMAIL,
  superuserPassword: env.POCKETBASE_SUPERUSER_PASSWORD,
  api: {
    enabled: true,
    // [!code highlight:1]
    apiKeys: { enabled: true },
  },
});
```

### The keys page

`/api-keys` lists a user's keys with their label, when they were made and when they were last used. Creating one shows the full key once, in a dialog with a copy button, and never again:

**src/routes/(app)/api-keys/new/+page.server.ts**

```ts
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { redirect } from "sveltekit-flash-message/server";
import { apiKeySchema } from "$lib/schemas/apiKey";
import { setPocketbaseErrors } from "@velastack/pocketbase/form";
import { createApiKey } from "./create-api-key";

export const load = async () => {
  return { form: await superValidate(zod4(apiKeySchema)) };
};

export const actions = {
  default: async ({ locals, request, cookies }) => {
    const form = await superValidate(request, zod4(apiKeySchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    let apiKey: string;

    try {
      apiKey = await createApiKey(
        locals.pb,
        locals.pb.authStore.record?.id,
        form.data.label,
      );
    } catch (error) {
      setPocketbaseErrors(form, error);
      return fail(400, { form });
    }

    return redirect(
      "/api-keys",
      {
        type: "success",
        title: "You've created a new API key",
        message: `Copy the following API key in a safe location. It will only be shown once.`,
        apiKey,
      },
      cookies,
    );
  },
};
```

Revoking is a delete on the user's own record, so a user can only ever remove their own keys:

**src/routes/(app)/api-keys/[id]/+page.server.ts**

```ts
import { redirect } from "@sveltejs/kit";

export const actions = {
  delete: async ({ locals, params }) => {
    await locals.pb.collection("api_keys").delete(params.id);
    redirect(303, "/api-keys");
  },
};
```

### Tests

Six tests, including the one that matters: a request without a key is refused, and the same request with a key succeeds as that user:

**src/routes/(app)/api-keys/new/server.test.ts**

```ts
import { describe, it, expect, beforeEach } from "vitest";
import type { Match } from "@velastack/kit";
import { createApiKey } from "./create-api-key";
import type { RouteId } from "./$types";

describe("GET /api-keys/new", () => {
  beforeEach(async (context) => {
    await context.agent.authenticateUser();
  });

  it("should return a 200 status code", async (context) => {
    const response = await context.agent.get(
      "/api-keys/new" satisfies Match<RouteId>,
    );
    expect(response.status).toBe(200);
  });
});

describe("POST /api-keys/new", () => {
  beforeEach(async (context) => {
    await context.agent.authenticateUser();
  });

  it("should create a new API key", async (context) => {
    const response = await context.agent
      .post("/api-keys/new")
      .type("form")
      .send({ label: "Test API Key" });
    expect(response.body.status).toBe(303);
    expect(response.body.location).toBe("/api-keys");
  });
});

describe("api keys authenticate as a user", () => {
  it("should create a new api key", async (context) => {
    const apiKey = await createApiKey(
      context.pb,
      context.user.id,
      "Test API Key",
    );
    expect(apiKey).toBeDefined();
  });

  it("should throw an error when creating for a non-existent user", async (context) => {
    await expect(async () => {
      await createApiKey(context.pb, "non-existent-user-id", "Test API Key");
    }).rejects.toThrow();
  });

  it("should give access to the api", async (context) => {
    const response = await context.request.get(
      `/api/collections/users/records/${context.user.id}`,
    );
    expect(response.status).toBe(404);

    const apiKey = await createApiKey(
      context.pb,
      context.user.id,
      "Test API Key",
    );
    const response2 = await context.request
      .get(`/api/collections/users/records/${context.user.id}`)
      .set("Authorization", `Bearer ${apiKey}`);
    expect(response2.status).toBe(200);
  });
});
```

## Try it

```sh
$ vela dev
```

Sign in, open [localhost:5173/api-keys](http://localhost:5173/api-keys), create a key called "laptop" and copy it. Then, from a terminal:

```sh
curl http://localhost:5173/api/collections/todos/records \
  -H "Authorization: Bearer <id>.<secret>"
```

You get the same records the signed-in user sees in the app, because the request is that user. Try it without the header and the list is empty or refused, depending on the collection's rules. Back on the keys page, "last used" has updated.

> **Note:**
> Keys authenticate PocketBase's own endpoints under `/api`: collections, records, files, realtime. Your own `+server.ts` routes still see the session cookie, not the key. For a read-only view of a page from a script, [content negotiation](https://velastack.dev/tutorials/sveltekit-markdown-for-ai-agents) is often the simpler tool.

## Going further

- Collection rules are the whole permission model, for the app and the API alike. [`vela generate migration`](https://velastack.dev/patterns/generate-migration) changes them alongside the fields
- Undo with `vela disable api-keys` and `vela disable api`
- [Pattern page: API Keys](https://velastack.dev/patterns/enable-api-keys) · [Pattern page: API endpoints](https://velastack.dev/patterns/enable-api) · [Docs: vela enable api-keys](https://docs.velastack.dev/enable/api-keys)
