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
✔ 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:
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:
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
✔ 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:
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:
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:
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:
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:
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
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.
/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
- Collection rules are the whole permission model, for the app and the API alike.
vela generate migrationchanges them alongside the fields - Undo with
vela disable api-keysandvela disable api - Pattern page: API Keys · Pattern page: API endpoints · Docs: vela enable api-keys


