Logo VelaStack

Enable API keys

Add a full API-keys UI and Bearer-token authentication for the PocketBase API. Authenticated users get an /api-keys page to list, create, and revoke keys; new secrets are hashed with argon2id and shown once in a copy dialog. Keys are issued in the form <id>.<secret>, and PocketBase requests made with Authorization: Bearer <key> authenticate as the owning user. Requires auth and api to be enabled first.

$ vela enable api-keys
src/lib/schemas/apiKey.ts
import { z } from "zod";
import type { Schemas } from "@velastack/pocketbase";

export const apiKeySchema = z.object({
  label: z.string(),
}) satisfies Schemas["api_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");
  },
};
src/routes/(app)/api-keys/+layout.server.ts
export const load = async ({ locals }) => {
  const apiKeys = await locals.pb.collection("api_keys").getFullList();
  return { apiKeys };
};
src/routes/(app)/api-keys/+layout.svelte
<script lang="ts">
  import { getFlash } from "sveltekit-flash-message";
  import { page } from "$app/state";
  import { toast } from "svelte-sonner";
  import * as AlertDialog from "$lib/components/ui/alert-dialog";
  import { Button } from "$lib/components/ui/button";
  import { Badge } from "$lib/components/ui/badge";
  import { timeAgo } from "$lib/utils";
  import PlusIcon from "@lucide/svelte/icons/plus";
  import SquareDashedMousePointerIcon from "@lucide/svelte/icons/square-dashed-mouse-pointer";

  const flash = getFlash(page);

  let open = $state(false);
  let message = $state("");
  let title = $state("");
  let apiKey = $state("");

  $effect(() => {
    if (!$flash) return;

    if ($flash.type === "success") {
      message = $flash.message;
      title = $flash.title;
      apiKey = $flash.apiKey;
      open = true;
    }

    $flash = undefined;
  });

  async function handleAction() {
    open = false;
    await navigator.clipboard.writeText(apiKey);
    toast.success("API key copied to clipboard");
  }

  let { data, children } = $props();
</script>

<section data-role="content">
  <div class="flex justify-between items-center mb-4">
    <h1 class="text-3xl font-bold tracking-tight">API keys</h1>
    <Button href="/api-keys/new" variant="outline">
      <PlusIcon class="w-4 h-4" />
      New API key
    </Button>
  </div>

  <AlertDialog.Root bind:open>
    <AlertDialog.Content class="max-w-120!">
      <AlertDialog.Header>
        <AlertDialog.Title>{title}</AlertDialog.Title>
        <AlertDialog.Description class="text-wrap">
          <p>{@html message}</p>
          <Badge variant="outline" class="font-mono my-3 text-base"
            >{apiKey}</Badge
          >
        </AlertDialog.Description>
      </AlertDialog.Header>
      <AlertDialog.Footer>
        <AlertDialog.Cancel>Close</AlertDialog.Cancel>
        <AlertDialog.Action onclick={handleAction}>Copy</AlertDialog.Action>
      </AlertDialog.Footer>
    </AlertDialog.Content>
  </AlertDialog.Root>

  <div class="rounded-lg border bg-card overflow-hidden">
    <div class="relative w-full overflow-auto">
      {#if data.apiKeys.length === 0}
        <div class="p-4 text-center text-muted-foreground space-y-4 py-12">
          <SquareDashedMousePointerIcon
            strokeWidth={1.2}
            class="w-8 h-8 mx-auto mb-2"
          />
          <p>
            Seems like there are no API keys yet, but you can create the first
            one
          </p>
          <p class="text-sm text-muted-foreground">
            <Button href="/api-keys/new" variant="outline">
              <PlusIcon class="w-4 h-4" />
              New API key
            </Button>
          </p>
        </div>
      {:else}
        <table class="w-full caption-bottom text-sm">
          <thead>
            <tr class="border-b">
              <th
                class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
                >Label</th
              >
              <th
                class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
                >API key</th
              >
              <th
                class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
                >Created</th
              >
              <th
                class="h-12 px-4 text-left align-middle font-medium text-muted-foreground"
                >Last used</th
              >
              <th
                class="h-12 px-4 text-right align-middle font-medium text-muted-foreground"
              ></th>
            </tr>
          </thead>
          <tbody>
            {#each data.apiKeys as apiKey}
              <tr
                class="border-t transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"
              >
                <td class="p-4 align-middle font-medium">{apiKey.label}</td>

                <td class="p-4 align-middle font-medium">
                  <Badge variant="outline" class="font-mono">
                    {apiKey.id}.***************
                  </Badge>
                </td>

                <td class="p-4 align-middle font-medium">
                  {#if apiKey.created}
                    <span title={new Date(apiKey.created).toLocaleString()}>
                      {timeAgo(apiKey.created)}
                    </span>
                  {/if}
                </td>

                <td class="p-4 align-middle font-medium">
                  {#if apiKey.last_used}
                    <span title={new Date(apiKey.last_used).toLocaleString()}>
                      {timeAgo(apiKey.last_used)}
                    </span>
                  {:else}
                    Never
                  {/if}
                </td>

                <td class="p-4 align-middle text-right">
                  <form
                    method="POST"
                    action="/api-keys/{apiKey.id}?/delete"
                    class="contents"
                  >
                    <Button
                      variant="ghost"
                      class="h-8 px-2 text-destructive hover:text-destructive"
                      type="submit">Delete</Button
                    >
                  </form>
                </td>
              </tr>
            {/each}
          </tbody>
        </table>
      {/if}
    </div>
  </div>
</section>

{@render children?.()}
src/routes/(app)/api-keys/+page.svelte
src/routes/(app)/api-keys/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "API keys",
  });

  return { pageMetaTags };
};
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";
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,
    );
  },
};
src/routes/(app)/api-keys/new/+page.svelte
<script lang="ts">
  import { untrack } from "svelte";
  import { superForm } from "sveltekit-superforms";
  import { zod4Client } from "sveltekit-superforms/adapters";
  import { apiKeySchema } from "$lib/schemas/apiKey";
  import * as Form from "$lib/components/ui/form";
  import { Input } from "$lib/components/ui/input";
  import { Button } from "$lib/components/ui/button";
  import * as Dialog from "$lib/components/ui/dialog";
  import { goto } from "$app/navigation";

  let { data } = $props();

  const form = superForm(
    untrack(() => data.form),
    {
      validators: zod4Client(apiKeySchema),
    },
  );

  const { form: formData } = form;

  function onOpenChange(open: boolean) {
    if (!open) {
      form.reset();
      goto("/api-keys");
    }
  }
</script>

<Dialog.Root open={true} {onOpenChange}>
  <Dialog.Content>
    <form method="POST">
      <div class="grid grid-cols-1 gap-4">
        <Form.Field {form} name="label" class="col-span-1">
          <Form.Control>
            {#snippet children({ props })}
              <Form.Label>API key label</Form.Label>
              <Input
                {...props}
                type="text"
                bind:value={$formData.label}
                autofocus
              />
            {/snippet}
          </Form.Control>
          <Form.FieldErrors class="contents text-destructive" />
        </Form.Field>
      </div>

      <div class="mt-4 flex gap-2 border-t pt-4 -mx-4 px-4">
        <Form.Button>Save</Form.Button>
        <Button href="/api-keys" variant="outline">Cancel</Button>
      </div>
    </form>
  </Dialog.Content>
</Dialog.Root>
src/routes/(app)/api-keys/new/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";

export const load = async ({ parent, data }) => {
  await parent();

  const { pageMetaTags } = definePageMetaTags({
    title: "New API key",
  });

  return { ...data, pageMetaTags };
};
src/routes/(app)/api-keys/new/create-api-key.ts
import PocketBase from "pocketbase-sveltekit";
import argon2 from "argon2";
import { randomBytes } from "node:crypto";

export const createApiKey = async (
  pb: PocketBase,
  userId: string | undefined,
  label: string,
) => {
  const keySecret = randomBytes(10).toString("hex");
  const keyHash = await argon2.hash(keySecret, { type: argon2.argon2id });
  const apiKey = await pb
    .collection("api_keys")
    .create({ key_hash: keyHash, user: userId, label });

  return `${apiKey.id}.${keySecret}`;
};
src/routes/(app)/api-keys/new/server.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import type { Match } from "@velastack/pocketbase";
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);
  });
});
src/routes/(app)/api-keys/server.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import type { Match } from "@velastack/pocketbase";
import type { RouteId } from "./$types";

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

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