npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@four-leaves/better-auth-api-keys

v0.1.1

Published

API key management plugin for Better Auth

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/action permissions (standalone) or permissionIds from the RBAC plugin
  • RBAC integration — gates key management endpoints behind apiKeys:create/read/update/delete permissions 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-keys

Setup

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 migrate

Or generate the SQL without applying it:

npx better-auth generate

3. 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/delete permission 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 record

Verify 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 seconds

sliding-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 window

Named 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:

  1. Endpoint authorization — tenant key management endpoints require the corresponding api-keys:* permission in the tenant.
  2. Key scoping — instead of { resource, action } objects, pass an array of permissionId strings when creating or updating a key's permissions.
  3. Seed helperseedApiKeyPermissions(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 tenant

Database 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.