API Keys & External API
- Create the api_keys table, key management UI, and public API routes for external integrations
Prerequisite
Complete the Get Started guide first.
Permissions
Add these permissions to shared/permissions.ts if they don't already exist:
"api_keys.view": ["owner", "admin"],
"api_keys.create": ["owner", "admin"],
"api_keys.revoke": ["owner", "admin"],Prompt 1 — API Keys
txt
Build the API key management system so external services can authenticate
with the app.
Database (via Supabase MCP):
Create an `api_keys` table — id (uuid, default gen_random_uuid(), primary
key), team_id (uuid, references teams(id) on delete cascade, not null),
created_by (uuid, references profiles(id), not null), name (text, not null),
key_prefix (text, not null), key_hash (text, not null), last_used_at
(timestamptz, nullable), expires_at (timestamptz, nullable), revoked_at
(timestamptz, nullable), created_at (timestamptz, default now()).
Enable RLS. Add select, insert, and update policies using `is_team_member()`
scoped to team_id.
Also add to realtime publication:
```sql
ALTER PUBLICATION supabase_realtime ADD TABLE api_keys;
ALTER TABLE api_keys REPLICA IDENTITY FULL;
```
Server routes (all use `authUser`):
- `GET /api/teams/[teamId]/api-keys` — uses
`authUser(event, "api_keys.view")`. Returns all API keys for the team
(NEVER return key_hash in the response), ordered by created_at desc.
Include the creator's name by joining profiles.
- `POST /api/teams/[teamId]/api-keys` — uses
`authUser(event, "api_keys.create")`. Reads { name } from the body.
Generates a new API key:
1. Generate 32 random bytes using Node's `crypto.randomBytes`
2. Base64url encode the bytes to create the raw key string
3. Create a prefix from the first 8 characters of the encoded key
4. Hash the full key with SHA-256 using Node's `crypto.createHash`
5. Convert the hash to a hex string
6. Store team_id, name, key_prefix, key_hash, and created_by
7. Return the full key in the response — this is the ONLY time it is
ever visible. The server never stores the raw key.
- `PATCH /api/teams/[teamId]/api-keys/[keyId]` — uses
`authUser(event, "api_keys.revoke")`. Sets revoked_at to now. Validates
the key belongs to the team and is not already revoked.
UI:
Add an "API Keys" tab to the settings page. Follow the existing tab pattern
in `app/pages/settings/index.vue` (or create `app/pages/settings/api-keys.vue`
if settings uses file-based routing for tabs).
The API Keys settings tab should:
- List all keys in a table showing: name, prefix (displayed as
`sk_...{prefix}`), created by (name), created date, last used date
(or "Never"), and status. Status is a badge: "Active" (green) if not
revoked and not expired, "Revoked" (red) if revoked_at is set,
"Expired" (neutral) if expires_at is in the past.
- A "Create Key" button at the top right, wrapped in
`CanAccess permission="api_keys.create"`. Opens a `UModal` with a
single field: key name (required). Show loading on submit.
- After creation, show a second modal displaying the full API key with
a copy-to-clipboard button and a warning message: "Copy this key now.
You will not be able to see it again." The modal should only be
dismissible after the user has seen the key.
- Each active key row has a "Revoke" button wrapped in
`CanAccess permission="api_keys.revoke"`. Clicking it shows a
confirmation modal before revoking. Show per-row loading state.
- Show an empty state when no keys exist with a message like
"No API keys yet. Create one to allow external services to access
your data."
- Show `USkeleton` placeholders on initial load.
Add "API Keys" to the settings navigation tabs with the icon
`i-solar-key-bold-duotone`.
Regenerate TypeScript types via Supabase MCP and save to
`shared/types/database.types.ts`.Prompt 2 — External API
txt
Build the external API so tools like n8n, Zapier, or custom integrations
can interact with the app's data programmatically.
1. Create `server/utils/authApiKey.ts` with an `authApiKey` function that
takes an H3 event. It should:
- Read the `Authorization` header and extract the Bearer token — throw
a 401 error if the header is missing or not in `Bearer <token>` format
- Hash the token with SHA-256 using Node's `crypto.createHash` (same
approach as the key generation step) and convert to hex
- Look up the hash in the `api_keys` table using the service role
Supabase client (`serverSupabaseServiceRole`)
- Throw 401 if not found
- Throw 401 if `revoked_at` is set (key has been revoked)
- Throw 401 if `expires_at` is set and is in the past (key has expired)
- Update `last_used_at` to now (fire and forget — do not await)
- Return `{ apiKey, client }` where `client` is the service role
Supabase client and `apiKey` is the full row including team_id
and created_by
2. Create public API routes under `/api/v1/`. These routes do NOT use
`authUser` — they use `authApiKey` instead. All queries are scoped to
the API key's `team_id`.
Analyze the app's existing database tables (exclude system tables like
teams, profiles, members, invitations, api_keys, rate_limits, and
schema_migrations). For each domain table found, create:
- `GET /api/v1/{table}` — returns all records for the key's team,
ordered by created_at desc. Support `?limit=` and `?offset=` for
pagination (default limit 50, max 100).
- `POST /api/v1/{table}` — creates a record. Accept all non-system
columns from the body. Set team_id from the API key and created_by
from the API key's created_by. Validate that required columns (not
null without defaults) are present.
- `PATCH /api/v1/{table}/[id]` — updates a record. Validate the record
belongs to the API key's team before updating.
Include relevant joins where foreign keys exist (e.g., if a table
references another by ID, include the referenced name in GET responses).
Both `authUser` (browser sessions) and `authApiKey` (API keys) return the
same service role Supabase client. The only difference is how the caller
authenticates. This is what makes API keys useful — external services can
manage data without needing a browser session.
Error responses for the v1 routes should use a consistent JSON format:
`{ "error": "message" }` with appropriate HTTP status codes (400 for
validation errors, 401 for auth errors, 404 for not found).Testing with curl
Once built, you can test the external API with curl:
bash
# List records
curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:3000/api/v1/your-table
# Create a record
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Example", "email": "test@example.com"}' \
http://localhost:3000/api/v1/your-table