Organization API Key
Organization-scoped API keys for Better Auth
The Organization API Key plugin lets you create and manage API keys that belong to an organization instead of a user.
This plugin is currently in development. APIs and behavior may change before stable release.
Features
- Create, update, verify, list, and delete organization API keys
- Built-in per-key rate limiting
- Custom expiration, remaining usage, and refill behavior
- Optional metadata and permissions on each key
- Custom key prefix, generator, and validator
Installation
Add plugin to the server
import { betterAuth } from "better-auth";
import { organizationApiKey } from "@better-auth-plugins/plugins/org-api-key";
export const auth = betterAuth({
plugins: [
organizationApiKey(),
],
});Migrate the database
pnpm dlx @better-auth/cli migratepnpm dlx @better-auth/cli generateAdd the client plugin
import { createAuthClient } from "better-auth/client";
import { organizationApiKeyClient } from "@better-auth-plugins/plugins/org-api-key";
export const authClient = createAuthClient({
plugins: [
organizationApiKeyClient(),
],
});This plugin expects organization models (for example organization and member).
Usage
Create an organization API key
const { data, error } = await authClient.organizationApiKey.createOrganizationApiKey({
organizationId: "org_123",
name: "CI key",
expiresIn: 60 * 60 * 24 * 30,
prefix: "org_live_",
metadata: {
env: "production",
},
});const data = await auth.api.createOrganizationApiKey({
body: {
organizationId: "org_123",
name: "CI key",
expiresIn: 60 * 60 * 24 * 30,
prefix: "org_live_",
remaining: 1000,
refillAmount: 1000,
refillInterval: 1000 * 60 * 60 * 24 * 30,
rateLimitEnabled: true, // server-only
rateLimitTimeWindow: 1000 * 60 * 60, // server-only
rateLimitMax: 500, // server-only
permissions: {
projects: ["read", "deploy"],
},
metadata: {
env: "production",
},
},
headers,
});type CreateOrganizationApiKey = {
organizationId: string;
name?: string;
expiresIn?: number | null;
prefix?: string;
remaining?: number | null; // server-only
refillAmount?: number; // server-only
refillInterval?: number; // server-only
rateLimitEnabled?: boolean; // server-only
rateLimitTimeWindow?: number | null; // server-only
rateLimitMax?: number | null; // server-only
permissions?: Record<string, string[]>; // server-only
metadata?: Record<string, unknown>;
};Result
Returns the created key object and the plaintext key value (shown once on creation).
Verify an organization API key
const { data, error } = await authClient.organizationApiKey.verifyOrganizationApiKey({
key: "org_live_xxx",
organizationId: "org_123",
permissions: {
projects: ["deploy"],
},
});const data = await auth.api.verifyOrganizationApiKey({
body: {
key: "org_live_xxx",
organizationId: "org_123",
permissions: {
projects: ["deploy"],
},
},
});type VerifyOrganizationApiKey = {
key?: string; // or use header
organizationId?: string;
permissions?: Record<string, string[]>;
};Result
type Result = {
valid: boolean;
error: { message: string; code: string; tryAgainIn?: number } | null;
key: OrganizationApiKeyPublicRecord | null;
};Get an organization API key
const { data, error } = await authClient.organizationApiKey.getOrganizationApiKey({
query: { id: "org_key_123" },
});const data = await auth.api.getOrganizationApiKey({
query: { id: "org_key_123" },
headers,
});Update an organization API key
const { data, error } = await authClient.organizationApiKey.updateOrganizationApiKey({
keyId: "org_key_123",
name: "Updated CI key",
enabled: true,
metadata: { team: "platform" },
});const data = await auth.api.updateOrganizationApiKey({
body: {
keyId: "org_key_123",
name: "Updated CI key",
enabled: true,
remaining: 250,
refillAmount: 500,
refillInterval: 1000 * 60 * 60 * 24,
rateLimitEnabled: true, // server-only
rateLimitTimeWindow: 1000 * 60 * 30, // server-only
rateLimitMax: 1000, // server-only
metadata: { team: "platform" },
permissions: {
projects: ["read"],
},
},
headers,
});type UpdateOrganizationApiKey = {
keyId: string;
name?: string;
enabled?: boolean;
expiresIn?: number | null;
remaining?: number | null; // server-only
refillAmount?: number | null; // server-only
refillInterval?: number | null; // server-only
rateLimitEnabled?: boolean; // server-only
rateLimitTimeWindow?: number | null; // server-only
rateLimitMax?: number | null; // server-only
metadata?: Record<string, unknown>;
permissions?: Record<string, string[]>; // server-only
};Delete an organization API key
const { data, error } = await authClient.organizationApiKey.deleteOrganizationApiKey({
keyId: "org_key_123",
});const data = await auth.api.deleteOrganizationApiKey({
body: { keyId: "org_key_123" },
headers,
});List organization API keys
const { data, error } = await authClient.organizationApiKey.listOrganizationApiKeys({
query: { organizationId: "org_123" },
});const data = await auth.api.listOrganizationApiKeys({
query: { organizationId: "org_123" },
headers,
});Verify using header key
By default, the plugin reads keys from x-org-api-key.
const data = await auth.api.verifyOrganizationApiKey({
body: {
organizationId: "org_123",
},
headers: new Headers({
"x-org-api-key": "org_live_xxx",
}),
});Rate Limiting
Each key has:
rateLimitEnabledrateLimitTimeWindow(milliseconds)rateLimitMax
Server-only key fields: remaining, refillAmount, refillInterval,
rateLimitEnabled, rateLimitTimeWindow, rateLimitMax, and permissions.
Rate limiting uses a sliding window based on lastRequest + requestCount.
If a key is over limit, verification returns RATE_LIMITED with tryAgainIn.
Remaining, Refill, and Expiration
remaining: requests left for the key.nullmeans unlimited.refillAmount+refillInterval: if both are set, usage refills torefillAmountwhen the interval passes.expiresIn: expiration in seconds when creating/updating.
When remaining reaches 0 and no refill is configured, verification returns USAGE_EXCEEDED.
Permissions
Permissions are resource-based:
type Permissions = {
[resource: string]: string[];
};You can set defaults for new keys:
organizationApiKey({
permissions: {
defaultPermissions: {
projects: ["read"],
},
},
});Or resolve defaults dynamically:
organizationApiKey({
permissions: {
defaultPermissions: async ({ organizationId, userId }) => {
return { projects: ["read"] };
},
},
});Metadata
Metadata is disabled by default. Enable it:
organizationApiKey({
enableMetadata: true,
});Custom key generation & verification
organizationApiKey({
customKeyGenerator: async ({ length, prefix }) => {
return `${prefix ?? ""}${crypto.randomUUID().replaceAll("-", "").slice(0, length)}`;
},
customAPIKeyValidator: async ({ key }) => {
return key.startsWith("org_");
},
});Plugin Options
apiKeyHeaders?: string | string[]defaultKeyLength?: numberdefaultPrefix?: stringminimumPrefixLength?: numbermaximumPrefixLength?: numberminimumNameLength?: numbermaximumNameLength?: numberrequireName?: booleanenableMetadata?: booleandisableKeyHashing?: booleanstartingCharactersConfig?: { shouldStore?: boolean; charactersLength?: number }rateLimit?: { enabled?: boolean; timeWindow?: number; maxRequests?: number }keyExpiration?: { defaultExpiresIn?: number | null; disableCustomExpiresTime?: boolean; minExpiresIn?: number; maxExpiresIn?: number }permissions?: { defaultPermissions?: Record<string, string[]> | ({ organizationId, userId, ctx }) => Record<string, string[]> | Promise<Record<string, string[]>> }managementRoles?: string[]tableName?: stringorganizationModel?: stringmemberModel?: stringcustomAPIKeyGetter?: (ctx) => string | nullcustomAPIKeyValidator?: ({ ctx, key }) => boolean | Promise<boolean>customKeyGenerator?: ({ length, prefix }) => string | Promise<string>
Endpoints
POST /organization/api-key/createGET /organization/api-key/listGET /organization/api-key/getPOST /organization/api-key/updatePOST /organization/api-key/deletePOST /organization/api-key/verify
Schema
Table: organizationApiKey (default model name)
| Field | Type | Description | Required |
|---|---|---|---|
id | string | Primary key | Yes |
name | string | Key display name | No |
start | string | First characters for UI identification | No |
prefix | string | Key prefix | No |
key | string | Hashed key value | Yes |
organizationId | string | Owning organization ID | Yes |
createdByUserId | string | Creator user ID | Yes |
enabled | boolean | Whether key is enabled | Yes |
rateLimitEnabled | boolean | Per-key rate-limit switch | Yes |
rateLimitTimeWindow | number | Rate-limit window in ms | No |
rateLimitMax | number | Max requests in window | No |
requestCount | number | Current requests in active window | Yes |
lastRequest | Date | Last request timestamp | No |
remaining | number | Remaining usage allowance | No |
refillAmount | number | Remaining reset target | No |
refillInterval | number | Refill interval in ms | No |
lastRefillAt | Date | Last refill timestamp | No |
expiresAt | Date | Expiration timestamp | No |
permissions | string | Serialized permissions | No |
metadata | string | Serialized metadata | No |
createdAt | Date | Created timestamp | Yes |
updatedAt | Date | Updated timestamp | Yes |
Not implemented in this plugin: secondary storage modes and session-from-api-key behavior.