Logo VelaStack

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.

3 min read apiapi-keysrestpocketbasesveltekit

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 enabled. Something to fetch helps: a scaffolded todos collection is used below.

Step 1: expose the API

$ 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
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 },
});

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

$ 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
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
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,
    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
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
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
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

$ vela dev

Sign in, open localhost:5173/api-keys, create a key called “laptop” and copy it. Then, from a terminal:

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 is often the simpler tool.

Going further

Related tutorials

How to add login and signup to a SvelteKit app

Login, signup, password reset, email verification, one-time codes and OAuth buttons, plus a settings page and a protected dashboard. vela enable auth generates all of it on PocketBase, with a server test for every route.

4 min read

How to add login with SvelteKit remote functions

The same login, signup, reset and settings flows as vela enable auth, with every form posting to a typed remote function instead of a form action. One flag, and each handler shrinks to a single function.

2 min read

How to add teams and invites to a SvelteKit app

Workspaces with members, roles and invites by email or link, and a team switcher in the sidebar. vela enable teams creates the collections, the pages and 36 tests, and gives every generator a current_team field for scoping data by team.

3 min read