@four-leaves/better-auth-api-keys
v0.1.1
Published
API key management plugin for Better Auth
Maintainers
Readme
better-auth-api-keys
A Better Auth plugin that adds API key management to your application. Works standalone or alongside better-auth-multi-tenancy and better-auth-multi-tenancy-rbac.
Features
- User and tenant keys — create API keys owned by individual users or entire tenants
- One-time plaintext delivery — the key is returned only once at creation; only the HMAC-SHA256 hash is stored
- Configurable rate limiting — fixed-window and sliding-window strategies, with named plan presets for billing tiers
- Key scoping — restrict what a key can do via
resource/actionpermissions (standalone) or permissionIds from the RBAC plugin - RBAC integration — gates key management endpoints behind
apiKeys:create/read/update/deletepermissions when used with the RBAC plugin - In-process LRU cache — reduces database round-trips on the hot verification path
- Full type safety — server and client plugins with TypeScript inference
- Database agnostic — works with any Better Auth adapter (SQLite, PostgreSQL, MySQL, …)
Installation
npm install @four-leaves/better-auth-api-keysSetup
1. Server
Add the plugin to your Better Auth configuration:
import { betterAuth } from 'better-auth'
import { apiKeys } from '@four-leaves/better-auth-api-keys'
export const auth = betterAuth({
// ... your existing config
plugins: [
apiKeys({
keyPrefix: 'sk_',
defaultRateLimit: {
type: 'fixed-window',
maxRequests: 1000,
windowMs: 60_000,
},
}),
],
})With multi-tenancy and RBAC
import { betterAuth } from 'better-auth'
import { multiTenancy } from '@four-leaves/better-auth-multi-tenancy'
import { rbac } from '@fourleaves/better-auth-multi-tenancy-rbac'
import {
apiKeys,
seedApiKeyPermissions,
} from '@four-leaves/better-auth-api-keys'
export const auth = betterAuth({
plugins: [multiTenancy(), rbac(), apiKeys({ useRbac: true })],
})
// Seed the four standard apiKeys permissions once on app startup:
await seedApiKeyPermissions(auth)2. Database migration
Run the Better Auth migration to create the apiKey table:
npx better-auth migrateOr generate the SQL without applying it:
npx better-auth generate3. Client
Add the client plugin to your auth client:
import { createAuthClient } from 'better-auth/client'
import { apiKeysClient } from '@four-leaves/better-auth-api-keys/client'
export const authClient = createAuthClient({
// ... your existing config
plugins: [apiKeysClient()],
})All methods are available under authClient.apiKeys.*.
API Reference
User-level Keys
These endpoints manage API keys owned by the currently authenticated user.
| Method | HTTP | Path | Description |
| -------------- | ---- | ------------------------- | ------------------------------------------ |
| createApiKey | POST | /api-keys | Create a new user API key |
| listApiKeys | GET | /api-keys | List all keys for the current user |
| getApiKey | GET | /api-keys/:keyId | Get a specific key by ID |
| updateApiKey | POST | /api-keys/:keyId | Update a key's name, scopes, or rate limit |
| deleteApiKey | POST | /api-keys/:keyId/delete | Permanently delete a key |
Create a key
const { data } = await authClient.apiKeys.createApiKey({
name: 'My Integration Key',
expiresAt: '2026-12-31T23:59:59Z', // optional
})
// The plaintext key is only available here — save it!
console.log(data.apiKey.key) // "sk_xk2mq9ht..."Create a key with rate limiting
const { data } = await authClient.apiKeys.createApiKey({
name: 'Rate-limited Key',
rateLimit: { type: 'sliding-window', maxRequests: 500, windowMs: 60_000 },
})Create a key with a named billing plan
// Requires rateLimitPlans to be configured in the plugin options
const { data } = await authClient.apiKeys.createApiKey({
name: 'Pro Plan Key',
rateLimitPlan: 'pro',
})Create a scoped key (standalone mode)
// Requires options.permissions to be configured in the plugin options
const { data } = await authClient.apiKeys.createApiKey({
name: 'Read-only Key',
permissions: [{ resource: 'documents', action: 'read' }],
})List all user keys
const { data } = await authClient.apiKeys.listApiKeys()Update a key
await authClient.apiKeys.updateApiKey({
params: { keyId: 'key-id' },
enabled: false, // disable without deleting
})Delete a key
await authClient.apiKeys.deleteApiKey({
params: { keyId: 'key-id' },
})Tenant-level Keys
These endpoints manage API keys owned by a tenant. Authorization depends on whether the RBAC plugin is in use:
- Without RBAC — only the tenant owner can create, update, or delete keys; any member can read.
- With RBAC — callers must hold the matching
apiKeys:create/read/update/deletepermission in the tenant.
| Method | HTTP | Path | Description |
| -------------------- | ---- | ------------------------------------------- | -------------------------- |
| createTenantApiKey | POST | /tenants/:tenantId/api-keys | Create a tenant API key |
| listTenantApiKeys | GET | /tenants/:tenantId/api-keys | List all keys for a tenant |
| getTenantApiKey | GET | /tenants/:tenantId/api-keys/:keyId | Get a specific tenant key |
| updateTenantApiKey | POST | /tenants/:tenantId/api-keys/:keyId | Update a tenant key |
| deleteTenantApiKey | POST | /tenants/:tenantId/api-keys/:keyId/delete | Delete a tenant key |
Create a tenant key
const { data } = await authClient.apiKeys.createTenantApiKey({
params: { tenantId: 'tenant-id' },
name: 'CI/CD Integration',
rateLimitPlan: 'pro',
})
console.log(data.apiKey.key) // plaintext — save it!Create a tenant key scoped to RBAC permissions
// useRbac: true — permissions is an array of permissionIds
const { data } = await authClient.apiKeys.createTenantApiKey({
params: { tenantId: 'tenant-id' },
name: 'Read-only Tenant Key',
permissions: ['permission-id-read'],
})Verification
POST /api-keys/verify — verifies an API key supplied in a request header. This endpoint does not require a session and is designed to be called server-to-server (e.g. from an API gateway or middleware layer).
The header name defaults to x-api-key and can be changed via the headerName option.
| Method | HTTP | Path | Description |
| -------------- | ---- | ------------------ | --------------------------------------- |
| verifyApiKey | POST | /api-keys/verify | Verify an API key (no session required) |
Verify a key
const { data } = await authClient.apiKeys.verifyApiKey(
{},
{ headers: { 'x-api-key': 'sk_xk2mq9ht...' } },
)
if (!data.valid) {
console.error(data.reason) // "API key not found.", "API key has expired.", …
// When rate limited, resetAt is also returned:
if (data.reason === 'Rate limit exceeded.') {
console.log(data.resetAt) // ISO timestamp — use for Retry-After
}
}
// On success:
console.log(data.userId) // owner user ID
console.log(data.tenantId) // owner tenant ID (or null for user-level keys)
console.log(data.apiKey) // full public key recordVerify with required permissions
// Standalone mode — pass ScopedPermission objects
const { data } = await authClient.apiKeys.verifyApiKey(
{ requiredPermissions: [{ resource: 'documents', action: 'read' }] },
{ headers: { 'x-api-key': 'sk_...' } },
)
// RBAC mode — pass permissionIds
const { data } = await authClient.apiKeys.verifyApiKey(
{ requiredPermissions: ['permission-id-read'] },
{ headers: { 'x-api-key': 'sk_...' } },
)The verify endpoint always returns HTTP 200. Check data.valid to determine the outcome.
Rate Limiting
Rate limits are stored per key and evaluated on every verification request. Two strategies are available:
fixed-window
The counter resets completely once windowMs milliseconds have elapsed since the window started. Simple and predictable, but can allow a burst of requests at each window boundary.
{ type: "fixed-window", maxRequests: 1000, windowMs: 60_000 }
// → 1000 requests per minute, hard reset every 60 secondssliding-window
The previous window's count decays proportionally as time passes, preventing boundary bursts. Better for smooth, continuous traffic.
{ type: "sliding-window", maxRequests: 1000, windowMs: 60_000 }
// → approximately 1000 requests per rolling 60-second windowNamed plan presets
Configure presets once in the plugin options and reference them by name at key creation time:
apiKeys({
rateLimitPlans: {
free: { type: 'fixed-window', maxRequests: 100, windowMs: 60_000 },
starter: { type: 'fixed-window', maxRequests: 1_000, windowMs: 60_000 },
pro: { type: 'sliding-window', maxRequests: 10_000, windowMs: 60_000 },
enterprise: {
type: 'sliding-window',
maxRequests: 100_000,
windowMs: 60_000,
},
},
})
// Then at key creation:
await authClient.apiKeys.createApiKey({ name: 'My Key', rateLimitPlan: 'pro' })RBAC Integration
When useRbac: true is set, the plugin integrates with better-auth-multi-tenancy-rbac:
- Endpoint authorization — tenant key management endpoints require the corresponding
api-keys:*permission in the tenant. - Key scoping — instead of
{ resource, action }objects, pass an array ofpermissionIdstrings when creating or updating a key's permissions. - Seed helper —
seedApiKeyPermissions(auth)creates the four standard permissions idempotently on startup.
The four seeded permissions are:
| Name | Resource | Action |
| ------------------ | ----------- | -------- |
| api-keys:create | api-keys | create |
| api-keys:read | api-keys | read |
| api-keys:update | api-keys | update |
| api-keys:delete | api-keys | delete |
Security
Key generation and storage
Each key is generated as a 64-character cryptographically random alphanumeric string (a-z, 0-9) using @better-auth/utils/random, then prepended with your configured keyPrefix. This gives over 300 bits of entropy — well beyond brute-force range.
The plaintext key is never stored. Only the HMAC-SHA256 digest (keyed with your BETTER_AUTH_SECRET) is persisted. Verification re-hashes the incoming key and compares against the stored digest.
Cache and immediate revocation
When the LRU cache is enabled (the default), a key disabled or deleted through the plugin's own endpoints is evicted from the cache immediately. However, if a key is mutated directly in the database (bypassing the plugin), the cached entry remains valid until its TTL expires (default 5 minutes).
If your threat model requires instant revocation in all cases, either disable the cache or set a short ttl:
apiKeys({
cache: { ttl: 0 }, // disable effective caching
// or
cache: { enabled: false }, // bypass cache entirely
})Keeping your secret safe
The HMAC digest is only as secure as your BETTER_AUTH_SECRET. Rotate the secret immediately if it is ever exposed — all existing keys will become unverifiable and will need to be reissued.
Plugin Options
apiKeys({
// Header the plugin reads the API key from during verification
headerName: "x-api-key", // default
// String prepended to newly generated keys
keyPrefix: "sk_", // default
// Applied to every new key when no per-key limit is specified
defaultRateLimit: { type: "fixed-window", maxRequests: 1000, windowMs: 60_000 },
// Named presets for billing tiers
rateLimitPlans: {
free: { type: "fixed-window", maxRequests: 100, windowMs: 60_000 },
pro: { type: "sliding-window", maxRequests: 5_000, windowMs: 60_000 },
},
// Available scopes in standalone mode (useRbac: false)
permissions: [
{ resource: "documents", action: "read" },
{ resource: "documents", action: "write" },
],
// Enable RBAC integration (requires better-auth-multi-tenancy-rbac)
useRbac: false, // default
// In-process LRU cache for verification lookups.
// Keys mutated directly in the database (not through plugin endpoints)
// remain valid in cache until TTL expires. Lower ttl or disable the cache
// if your threat model requires immediate revocation.
cache: {
enabled: true, // default
maxSize: 1000, // default
ttl: 300_000, // default (5 minutes)
},
// Lifecycle hooks
onApiKeyCreated: async (key) => { ... },
onApiKeyDeleted: async (key) => { ... },
onApiKeyVerified: async (key) => { ... },
})All callbacks are optional and may be async. Deleted-entity callbacks receive the record as it existed immediately before deletion.
Full Example
// 1. Configure auth
import { betterAuth } from 'better-auth'
import { multiTenancy } from 'better-auth-multi-tenancy'
import { rbac } from '@fourleaves/better-auth-multi-tenancy-rbac'
import { apiKeys, seedApiKeyPermissions } from 'better-auth-api-keys'
export const auth = betterAuth({
plugins: [
multiTenancy(),
rbac(),
apiKeys({
useRbac: true,
headerName: 'x-api-key',
rateLimitPlans: {
free: { type: 'fixed-window', maxRequests: 100, windowMs: 60_000 },
pro: { type: 'sliding-window', maxRequests: 5_000, windowMs: 60_000 },
},
onApiKeyCreated: async (key) => {
console.log(`Key "${key.name}" created`)
},
}),
],
})
// 2. Seed permissions on startup
await seedApiKeyPermissions(auth)
// 3. Create a tenant key (requires apiKeys:create permission in the tenant)
const { data } = await authClient.apiKeys.createTenantApiKey({
params: { tenantId: 'tenant-id' },
name: 'CI/CD Deploy Key',
rateLimitPlan: 'pro',
})
// Save the key — it's only shown once!
const plainKey = data.apiKey.key
// 4. Verify the key in your API gateway / middleware
const { data: result } = await authClient.apiKeys.verifyApiKey(
{},
{ headers: { 'x-api-key': plainKey } },
)
if (!result.valid) {
throw new Error(result.reason)
}
console.log(result.userId) // user who created the key
console.log(result.tenantId) // owning tenantDatabase Schema
The plugin creates one table:
| Table | Description |
| -------- | ------------------------------------------------------------------------------------ |
| apiKey | Stores hashed API keys with their rate-limit state, scoped permissions, and metadata |
Key columns:
| Column | Type | Description |
| ----------------- | ------- | ----------------------------------------------------------------- |
| name | string | Human-readable label |
| prefix | string | First few characters of the original key — safe to display in UIs |
| hashedKey | string | HMAC-SHA256 digest of the plaintext key (indexed, unique) |
| userId | string? | Owning user (user-level keys) |
| tenantId | string? | Owning tenant (tenant-level keys) |
| permissions | string? | Serialised JSON scope array |
| enabled | boolean | Whether the key is active |
| expiresAt | date? | Optional expiry timestamp |
| rateLimitConfig | string? | Serialised RateLimitConfig JSON |
| requestCount | number | Requests counted in the current window |
| remaining | number? | Requests remaining in the current window |
| lastRequest | date? | Timestamp used for window calculation |
| lastUsedAt | date? | Timestamp of the most recent successful verification |
License
MIT — see LICENSE.
