Advanced Features

Advanced API Key features including sessions, multiple configurations, organization keys, storage modes, and more.

Sessions from API keys

Any time an endpoint in Better Auth is called that has a valid API key in the headers, you can automatically create a mock session to represent the user by enabling enableSessionForAPIKeys option.

This is generally not recommended, as it can lead to security issues if not used carefully. A leaked api key can be used to impersonate a user.

User-Owned Keys Only: Session mocking only works with user-owned API keys (where references: "user"). Organization-owned keys cannot mock user sessions.

Rate Limiting Note: When enableSessionForAPIKeys is enabled, the API key is validated once per request, and rate limiting is applied accordingly. If you manually verify an API key and then fetch a session separately, both operations will increment the rate limit counter. Using enableSessionForAPIKeys avoids this double increment.

import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey({
      enableSessionForAPIKeys: true,
    }),
  ],
});
import { auth } from "@/lib/auth"

const session = await auth.api.getSession({
      headers: new Headers({
            'x-api-key': apiKey,
      }),
});

The default header key is x-api-key, but this can be changed by setting the apiKeyHeaders option in the plugin options.

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey({
      apiKeyHeaders: ["x-api-key", "xyz-api-key"], // or you can pass just a string, eg: "x-api-key"
    }),
  ],
});

Or optionally, you can pass a customAPIKeyGetter function to the plugin options, which will be called with the HookEndpointContext, and from there, you should return the API key, or null if the request is invalid.

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "better-auth/plugins"

export const auth = betterAuth({
  plugins: [
    apiKey({
      customAPIKeyGetter: (ctx) => {
        const has = ctx.request.headers.has("x-api-key");
        if (!has) return null;
        return ctx.request.headers.get("x-api-key");
      },
    }),
  ],
});

Multiple Configurations

You can define multiple API key configurations with different settings. Each configuration is identified by a unique configId and can have its own prefix, rate limits, permissions, and other options.

This is useful when you need different types of API keys for different purposes, such as:

  • Public vs private API keys
  • Read-only vs read-write keys
  • Different rate limits for different tiers

Setup

Pass an array of configuration objects to the apiKey plugin:

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey([
      {
        configId: "public",
        defaultPrefix: "pk_",
        rateLimit: {
          enabled: true,
          maxRequests: 100,
          timeWindow: 1000 * 60 * 60, // 1 hour
        },
      },
      {
        configId: "secret",
        defaultPrefix: "sk_",
        enableMetadata: true,
        rateLimit: {
          enabled: true,
          maxRequests: 1000,
          timeWindow: 1000 * 60 * 60, // 1 hour
        },
      },
    ]),
  ],
});

Creating Keys with Specific Configurations

When creating an API key, specify which configuration to use via the configId parameter:

create-api-key.ts
import { auth } from "@/lib/auth"

// Create a public key
const publicKey = await auth.api.createApiKey({
  body: {
    configId: "public",
    userId: user.id,
  },
});
// Result: pk_...

// Create a secret key
const secretKey = await auth.api.createApiKey({
  body: {
    configId: "secret",
    userId: user.id,
    metadata: { plan: "premium" },
  },
});
// Result: sk_...

Note that when creating a key under a specific configId, the get, update, delete and verify operations must also specify the configId parameter.

Using configId for API Key Operations

All API key operations support the configId parameter to specify which configuration to use for the lookup. This is important when different configurations have different storage backends (e.g., database vs Redis):

api-key-operations.ts
// Get an API key using a specific config
const key = await auth.api.getApiKey({
  query: { 
    id: keyId,
    configId: "secret" 
  },
  headers,
});

// Update an API key using a specific config
await auth.api.updateApiKey({
  body: {
    keyId: keyId,
    configId: "secret",
    name: "Updated Name",
  },
});

// Delete an API key using a specific config
await auth.api.deleteApiKey({
  body: {
    keyId: keyId,
    configId: "secret",
  },
  headers,
});

// Verify an API key using a specific config
const result = await auth.api.verifyApiKey({
  body: {
    key: apiKeyValue,
    configId: "secret",
  },
});

Filtering Keys by Configuration

When listing API keys, you can filter by configId:

list-api-keys.ts
// List only public keys
const publicKeys = await authClient.apiKey.list({
  query: { configId: "public" }
});

// List only secret keys
const secretKeys = await authClient.apiKey.list({
  query: { configId: "secret" }
});

Global Options

You can also pass global options (like schema) as a second argument:

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey(
      [
        { configId: "public", defaultPrefix: "pk_" },
        { configId: "secret", defaultPrefix: "sk_" },
      ],
      {
        schema: {
          // Custom schema options
        },
      }
    ),
  ],
});

Organization-Owned API Keys

By default, API keys are owned by users. However, you can configure API keys to be owned by organizations instead. This is useful for team-based applications where API keys should be shared across organization members.

Configuration

Set references: "organization" in your configuration:

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey([
      {
        configId: "user-keys",
        defaultPrefix: "user_",
        references: "user", // Default - owned by users
      },
      {
        configId: "org-keys",
        defaultPrefix: "org_",
        references: "organization", // Owned by organizations
      },
    ]),
  ],
});

Creating Organization-Owned Keys

When creating an organization-owned API key, pass the organizationId instead of userId:

create-org-api-key.ts
import { auth } from "@/lib/auth"

const orgKey = await auth.api.createApiKey({
  body: {
    configId: "org-keys",
    organizationId: "org_123", // Required for org-owned keys
  },
});

Session Mocking Limitation: The enableSessionForAPIKeys feature only works with user-owned API keys. Organization-owned keys cannot mock user sessions, as there's no single user associated with the key.

Access Control & Permissions

Organization-owned API keys use the organization plugin's role-based access control system. To manage organization API keys, users must:

  1. Be a member of the organization
  2. Have the appropriate apiKey permission for the action they're performing

API Key Permissions

The API key plugin uses the following permissions:

ActionPermissionDescription
CreateapiKey: ["create"]Create new organization API keys
Read/ListapiKey: ["read"]View and list organization API keys
UpdateapiKey: ["update"]Modify organization API keys
DeleteapiKey: ["delete"]Delete organization API keys

Configuring Organization Roles

By default, organization owners have full access to all API key operations. For other roles (like admin or member), you need to explicitly configure apiKey permissions in your organization plugin setup.

Here's how to configure roles with API key permissions:

auth.ts
import { betterAuth } from "better-auth"
import { organization } from "better-auth/plugins"
import { apiKey } from "@better-auth/api-key"
import { createAccessControl } from "better-auth/plugins/access"

// Define your access control statements including apiKey
const statements = {
  // ... other statements
  // Add apiKey permissions
  apiKey: ["create", "read", "update", "delete"], 
} as const;

const ac = createAccessControl(statements);

// Define roles with specific apiKey permissions
const adminRole = ac.newRole({
  // ... other statements
  // Admins can manage API keys
  apiKey: ["create", "read", "update", "delete"], 
});

const memberRole = ac.newRole({
  // ... other statements
  // Members can only view API keys
  apiKey: ["read"], 
});

export const auth = betterAuth({
  plugins: [
    organization({
      ac,
      roles: {
        admin: adminRole,
        member: memberRole,
      },
      async sendInvitationEmail() {},
    }),
    apiKey([
      {
        configId: "org-keys",
        defaultPrefix: "org_",
        references: "organization",
      },
    ]),
  ],
});

Owner Access: Organization owners (the creatorRole, default "owner") automatically have full access to all API key operations, regardless of explicit permission configuration.

Permission Examples

// Admin can create, read, update, delete org API keys
const key = await auth.api.createApiKey({
  body: { configId: "org-keys", organizationId: "org_123" },
  headers: adminHeaders,
});

// Member with only "read" permission can list keys
const keys = await client.apiKey.list(
  { query: { organizationId: "org_123" } },
  { headers: memberHeaders },
);

// Member trying to create a key will get FORBIDDEN error
const result = await client.apiKey.create(
  { configId: "org-keys", organizationId: "org_123" },
  { headers: memberHeaders },
);
// Error: INSUFFICIENT_API_KEY_PERMISSIONS

Error Codes

When access is denied, the following error codes are returned:

  • USER_NOT_MEMBER_OF_ORGANIZATION: The user is not a member of the organization
  • INSUFFICIENT_API_KEY_PERMISSIONS: The user doesn't have the required apiKey permission for the action

API Key Object Structure

API keys include a configId to identify which configuration they belong to, and a referenceId for the owner:

type ApiKey = {
  id: string;
  configId: string;    // The configuration this key belongs to
  referenceId: string; // The owner ID (userId or organizationId based on config)
  // ... other fields
};

The owner type (user vs organization) is determined by looking up the configuration:

const apiKey = await auth.api.getApiKey({
  query: { id: keyId },
  headers,
});

// The owner type is determined by the config's `references` setting
// For org-keys config (references: "organization"):
console.log(`Key owned by: ${apiKey.referenceId}`);

Storage Modes

The API Key plugin supports multiple storage modes for flexible API key management, allowing you to choose the best strategy for your use case.

Storage Mode Options

"database" (Default)

Store API keys only in the database adapter. This is the default mode and requires no additional configuration.

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey({
      storage: "database", // Default, can be omitted
    }),
  ],
});

"secondary-storage"

Store API keys only in secondary storage (e.g., Redis). No fallback to database. Best for high-performance scenarios where all keys are migrated to secondary storage.

auth.ts
import { createClient } from "redis";
import { betterAuth } from "better-auth";
import { apiKey } from "@better-auth/api-key";

const redis = createClient();
await redis.connect();

export const auth = betterAuth({
  secondaryStorage: {
    get: async (key) => await redis.get(key),
    set: async (key, value, ttl) => {
      if (ttl) await redis.set(key, value, { EX: ttl });
      else await redis.set(key, value);
    },
    delete: async (key) => await redis.del(key),
  },
  plugins: [
    apiKey({
      storage: "secondary-storage",
    }),
  ],
});

Secondary Storage with Fallback

Check secondary storage first, then fallback to database if not found.

Read behavior:

  • Checks secondary storage first
  • If not found, queries the database
  • Automatically populates secondary storage when falling back to database (cache warming)
  • Ensures frequently accessed keys stay in cache over time

Write behavior:

  • Writes to both database and secondary storage
  • Ensures consistency between both storage layers
auth.ts
import { betterAuth } from "better-auth"
import { createClient } from "redis";

const redis = createClient();
await redis.connect();

export const auth = betterAuth({
  secondaryStorage: {
    get: async (key) => await redis.get(key),
    set: async (key, value, ttl) => {
      if (ttl) await redis.set(key, value, { EX: ttl });
      else await redis.set(key, value);
    },
    delete: async (key) => await redis.del(key),
  },
  plugins: [
    apiKey({
      storage: "secondary-storage",
      fallbackToDatabase: true,
    }),
  ],
});

Custom Storage Methods

You can provide custom storage methods specifically for API keys, overriding the global secondaryStorage configuration:

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey({
      storage: "secondary-storage",
      customStorage: {
        get: async (key) => {
          // Custom get logic for API keys
          return await customStorage.get(key);
        },
        set: async (key, value, ttl) => {
          // Custom set logic for API keys
          await customStorage.set(key, value, ttl);
        },
        delete: async (key) => {
          // Custom delete logic for API keys
          await customStorage.delete(key);
        },
      },
    }),
  ],
});

Rate Limiting

Every API key can have its own rate limit settings. The built-in rate-limiting applies whenever an API key is validated, which includes:

  • When verifying an API key via the /api-key/verify endpoint
  • When using API keys for session creation (if enableSessionForAPIKeys is enabled), rate limiting applies to all endpoints that use the API key

For other endpoints/methods that don't use API keys, you should utilize Better Auth's built-in rate-limiting.

Double Rate-Limit Increment: If you manually verify an API key using verifyApiKey() and then fetch a session using getSession() with the same API key header, both operations will increment the rate limit counter, resulting in two increments for a single request. To avoid this, either:

  • Use enableSessionForAPIKeys: true and let Better Auth handle session creation automatically (recommended)
  • Or verify the API key once and reuse the validated result instead of calling both methods separately

You can refer to the rate-limit default configurations below in the API Key plugin options.

An example default value:

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey({
      rateLimit: {
        enabled: true,
        timeWindow: 1000 * 60 * 60 * 24, // 1 day
        maxRequests: 10, // 10 requests per day
      },
    }),
  ],
});

For each API key, you can customize the rate-limit options on create.

You can only customize the rate-limit options on the server auth instance.

create-api-key.ts
import { auth } from "@/lib/auth"

const apiKey = await auth.api.createApiKey({
  body: {
    rateLimitEnabled: true,
    rateLimitTimeWindow: 1000 * 60 * 60 * 24, // 1 day
    rateLimitMax: 10, // 10 requests per day
  },
  headers: await headers() // headers containing the user's session token
});

How does it work?

The rate limiting system uses a sliding window approach:

  1. First Request: When an API key is used for the first time (no previous lastRequest), the request is allowed and requestCount is set to 1.

  2. Within Window: For subsequent requests within the timeWindow, the requestCount is incremented. If requestCount reaches rateLimitMax, the request is rejected with a RATE_LIMITED error code.

  3. Window Reset: If the time since the last request exceeds the timeWindow, the window resets: requestCount is reset to 1 and lastRequest is updated to the current time.

  4. Rate Limit Exceeded: When a request is rejected due to rate limiting, the error response includes a tryAgainIn value (in milliseconds) indicating how long to wait before the window resets.

Disabling Rate Limiting:

  • Globally: Set rateLimit.enabled: false in plugin options
  • Per Key: Set rateLimitEnabled: false when creating or updating an API key
  • Null Values: If rateLimitTimeWindow or rateLimitMax is null, rate limiting is effectively disabled for that key

When rate limiting is disabled (globally or per-key), requests are still allowed but lastRequest is updated for tracking purposes.

Remaining, refill, and expiration

The remaining count is the number of requests left before the API key is disabled. The refill interval is the interval in milliseconds where the remaining count is refilled when the interval has passed since the last refill (or since creation if no refill has occurred yet). The expiration time is the expiration date of the API key.

How does it work?

Remaining:

Whenever an API key is used, the remaining count is updated. If the remaining count is null, then there is no cap to key usage. Otherwise, the remaining count is decremented by 1. If the remaining count is 0, then the API key is disabled & removed.

refillInterval & refillAmount:

Whenever an API key is created, the refillInterval and refillAmount are set to null by default. This means that the API key will not be refilled automatically. However, if both refillInterval & refillAmount are set, then whenever the API key is used:

  • The system checks if the time since the last refill (or since creation if no refill has occurred) exceeds the refillInterval
  • If the interval has passed, the remaining count is reset to refillAmount (not incremented)
  • The lastRefillAt timestamp is updated to the current time

Expiration:

Whenever an API key is created, the expiresAt is set to null. This means that the API key will never expire. However, if the expiresIn is set, then the API key will expire after the expiresIn time.

Custom Key generation & verification

You can customize the key generation and verification process straight from the plugin options.

Here's an example:

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey({
      customKeyGenerator: (options: {
        length: number;
        prefix: string | undefined;
      }) => {
        const apiKey = mySuperSecretApiKeyGenerator(
          options.length,
          options.prefix
        );
        return apiKey;
      },
      customAPIKeyValidator: async ({ ctx, key }) => {
        const res = await keyService.verify(key)
        return res.valid
      },
    }),
  ],
});

If you're not using the length property provided by customKeyGenerator, you must set the defaultKeyLength property to how long generated keys will be.

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey({
      customKeyGenerator: () => {
        return crypto.randomUUID();
      },
      defaultKeyLength: 36, // Or whatever the length is
    }),
  ],
});

If an API key is validated from your customAPIKeyValidator, we still must match that against the database's key. However, by providing this custom function, you can improve the performance of the API key verification process, as all failed keys can be invalidated without having to query your database.

Metadata

We allow you to store metadata alongside your API keys. This is useful for storing information about the key, such as a subscription plan for example.

To store metadata, make sure you haven't disabled the metadata feature in the plugin options.

auth.ts
import { betterAuth } from "better-auth"
import { apiKey } from "@better-auth/api-key"

export const auth = betterAuth({
  plugins: [
    apiKey({
      enableMetadata: true,
    }),
  ],
});

Then, you can store metadata in the metadata field of the API key object.

create-api-key.ts
import { auth } from "@/lib/auth"

const apiKey = await auth.api.createApiKey({
  body: {
    metadata: { 
      plan: "premium", 
    }, 
  },
});

You can then retrieve the metadata from the API key object.

get-api-key.ts
import { auth } from "@/lib/auth"

const apiKey = await auth.api.getApiKey({
  body: {
    keyId: "your_api_key_id_here",
  },
});

console.log(apiKey.metadata.plan); // "premium"