API Key
Create, manage, and validate API keys with rate limiting and usage tracking.
The ApiKeyPlugin provides API key management with support for rate limiting, usage quotas, expiration, and permissions.
Setup
These examples assume you've already defined AppAuthSchema. If you want a complete setup from entity definitions through Axum mounting, start with the Axum integration guide.
use better_auth::plugins::ApiKeyPlugin;
let auth = BetterAuth::<AppAuthSchema>::new(config)
.store(store)
.plugin(ApiKeyPlugin::builder().build())
.build()
.await?;Configuration
use better_auth::plugins::api_key::{ApiKeyConfig, RateLimitDefaults, KeyExpirationConfig};
let auth = BetterAuth::<AppAuthSchema>::new(config)
.store(store)
.plugin(
ApiKeyPlugin::builder()
.key_length(64)
.prefix("sk_".to_string())
.enable_metadata(true)
.build()
)
.build()
.await?;| Option | Type | Default | Description |
|---|---|---|---|
key_length | usize | 64 | Random characters excluding the prefix; 0 selects the default length of 64, matching upstream |
prefix | Option<String> | None | Global prefix for all generated keys |
enable_metadata | bool | false | Allow storing metadata on keys |
require_name | bool | false | Require a name when creating keys |
default_permissions | Option<HashMap<String, Vec<String>>> | None | Permissions assigned when creation omits permissions |
api_key_headers | Vec<String> | ["x-api-key"] | Headers checked when API key sessions are enabled |
enable_session_for_api_keys | bool | false | Emulate sessions for API-key-authenticated requests |
config_id | String | "default" | Name of this configuration, stored on every key it creates |
references | ApiKeyReferences | User | Whether keys belong to the signed-in user or to an organization |
Named Configurations and Organization-Owned Keys
A plugin instance holds one or more configurations. Each is identified by its config_id, and clients select one by sending configId. Creation, lookup, update, and deletion use the "default" configuration when configId is absent. Listing without configId includes all registered configurations for the requested owner type. Register additional configurations with configuration:
use better_auth::plugins::api_key::{ApiKeyConfig, ApiKeyReferences};
let auth = BetterAuth::<AppAuthSchema>::new(config)
.store(store)
.plugin(OrganizationPlugin::new())
.plugin(
ApiKeyPlugin::builder()
.prefix("sk_".to_string())
.build()
.configuration(ApiKeyConfig {
config_id: "org".to_string(),
references: ApiKeyReferences::Organization,
prefix: Some("org_".to_string()),
..Default::default()
}),
)
.build()
.await?;When a configuration sets references: ApiKeyReferences::Organization, its keys belong to an organization instead of a user:
createandlistrequire anorganizationId(body field or query parameter respectively).get,update, anddeleteresolve the organization from the key itself.- The caller must be a member of that organization and hold the matching
apikeyaction (create,read,update, ordelete) in the organization plugin's RBAC. The creator role is allowed every action. The default roles grant none, so applications grant them through custom roles; see Organization RBAC. - The organization plugin must be registered, or requests fail with
ORGANIZATION_PLUGIN_REQUIRED.
Every key carries referenceId (the owning user or organization id) and configId in its response.
How It Works
Key Generation
API keys are generated as random alphabetic strings ([a-zA-Z]) of the configured length. The key format is:
{prefix}{random_alpha_chars}Only the SHA-256 hash of the key is stored in the database. The full key is returned only once during creation and cannot be retrieved later.
Key Structure
Each API key has:
start: First 6 characters of the full key including prefix (for UI identification)prefix: Optional prefix (e.g.,sk_,pk_)enabled: Whether the key is activeremaining: Usage quota (decremented on each use,null= unlimited)referenceId: Owner of the key: a user id, or an organization id for organization-owned configurationsconfigId: The configuration the key was created underexpiresAt: Optional expiration date (set viaexpiresInin seconds)- Rate limiting: Counter-based rate limits with configurable time window and max requests
API Endpoints
The API Key plugin exposes 5 client-facing endpoints. All require an authenticated session.
| Endpoint | Method | Description |
|---|---|---|
/api-key/create | POST | Create a new API key (returns key only once) |
/api-key/get | GET | Get key metadata by ID (query param id) |
/api-key/list | GET | List keys for the authenticated user or an organization (paginated) |
/api-key/update | POST | Update key settings (body field keyId) |
/api-key/delete | POST | Delete an API key (body field keyId) |
Listing
GET /api-key/list returns a paginated envelope rather than a bare array:
{
"apiKeys": [ ... ],
"total": 42,
"limit": 20,
"offset": 0
}| Query parameter | Description |
|---|---|
configId | Configuration to list keys from; absent lists all registered configurations for the requested owner type |
organizationId | Selects organization-owned keys; omit to list user-owned keys |
limit, offset | Page window; total counts the whole result set and both fields are omitted when not sent |
sortBy | One of createdAt, updatedAt, name, expiresAt |
sortDirection | asc (default) or desc |
The full key value is returned only during creation. Store it securely — it cannot be retrieved later.
Fields like permissions, remaining, refillAmount, refillInterval, rateLimitEnabled, rateLimitTimeWindow, and rateLimitMax are server-only and cannot be set from HTTP client requests.
Server-only Operations
Use auth.api_keys()? to access the registered plugin and initialized context from trusted Rust code. The accessor returns a configuration error when ApiKeyPlugin is not registered.
use std::collections::HashMap;
use better_auth::plugins::ApiKeyPlugin;
use better_auth::server_api::{
CreateKeyOptions, RequiredActions, UpdateKeyOptions, VerifyKeyOptions,
};
let auth = BetterAuth::<AppAuthSchema>::new(config)
.store(store)
.plugin(ApiKeyPlugin::builder().build())
.build()
.await?;
let api_keys = auth.api_keys()?;
let issued = api_keys.create(user_id, CreateKeyOptions {
name: Some("worker".to_owned()),
permissions: Some(HashMap::from([
("nodes".to_owned(), vec!["heartbeat".to_owned()]),
])),
..Default::default()
}).await?;
let key = api_keys.verify(&issued.key, VerifyKeyOptions {
config_id: Some("default".to_owned()),
permissions: Some(HashMap::from([
("nodes".to_owned(), RequiredActions::All(vec!["heartbeat".to_owned()])),
])),
}).await?;
api_keys.update(user_id, &key.id, UpdateKeyOptions {
enabled: Some(false),
..Default::default()
}).await?;Creation and update require the acting user's ID as a method argument. Organization configurations also require organization_id during creation and check the acting user's organization permissions. Verification requires the key, without a user session. No public HTTP verification endpoint is exposed.
Use RequiredActions::All for upstream AND semantics or RequiredActions::Any for OR semantics within a resource. Every requested resource must pass. Numeric options use f64 to preserve upstream fractional values.
Use FieldUpdate::Unchanged, FieldUpdate::Clear, or FieldUpdate::Set(value) for nullable updates to permissions, metadata, and expires_in. Other omitted update fields remain unchanged. For example:
use better_auth::server_api::FieldUpdate;
api_keys.update(user_id, &key.id, UpdateKeyOptions {
expires_in: FieldUpdate::Clear,
..Default::default()
}).await?;The lower-level ApiKeyPlugin::create_key, update_key, and verify_api_key methods remain available for callers that already manage an AuthContext and wire request DTOs.
Verification checks configuration, expiration, permissions, quota, and rate limits. A permission mismatch does not consume quota. To match Better Auth 1.7.6, a rate-limit rejection consumes quota before returning RATE_LIMITED; details.tryAgainIn uses milliseconds. Expired keys and exhausted keys without a refill amount are deleted during verification.
Handle ApiKeyVerificationError::Validation as a rejected credential and propagate ApiKeyVerificationError::Internal as an infrastructure failure. The successful result includes reference_id, config_id, permissions, and updated counters. It omits the plaintext key and stored hash.
Pass config_id when a route accepts credentials from one configuration. Without that field, lookup uses the default configuration's hashing setting, then validates against the configuration that issued the key.
Session Emulation
Enable enable_session_for_api_keys to authenticate user-owned keys through the configured headers. The plugin provides a virtual session to protected routes without creating a database session. Its hook answers both GET and POST requests to /get-session, matching upstream behavior. Organization-owned keys cannot create user sessions. Session emulation grants access as the key owner; it does not enforce the key’s resource permissions on each route. For scoped machine credentials, keep session emulation disabled and pass the required permissions to server-side verification.
Numeric Storage
Better Auth accepts fractional quota, refill, and rate-limit values. Rust uses f64 for those fields and request_count. The bundled migration converts existing PostgreSQL columns to double precision and rebuilds SQLite API key tables with real-valued columns, preserving existing keys. Applications that own their migrations must update the corresponding columns and model fields.
Errors
| Status | Condition |
|---|---|
| 400 | Missing or invalid required fields |
| 400 | ORGANIZATION_ID_REQUIRED |
| 403 | USER_NOT_MEMBER_OF_ORGANIZATION or INSUFFICIENT_API_KEY_PERMISSIONS |
| 500 | ORGANIZATION_PLUGIN_REQUIRED |
| 401 | Not authenticated |
| 404 | API key not found, not owned by the caller, or not in the addressed configuration |