Many SaaS products need to let their own customers authenticate against an API programmatically. Oolvay ships the scaffolding for that, a dedicated /developer/api-keys page where a signed-in user can generate, name, expire, and revoke their own keys, backed by Better Auth’s API key plugin and a Drizzle schema that stores them.
This is scaffolding, not an authorization system. Oolvay handles key generation, storage, display, and revocation. It does not decide what a key is allowed to do once it is generated. That decision, checking a key’s scope or permissions against an incoming request, is something you build into your own API routes based on your product’s access model.
Before exposing API keys to your users, review config/api-keys.ts.
export const API_KEY_SCOPES = ["read", "write", "delete", "admin"
0 / 2,000 characters
API_KEY_SCOPES defines the permissions available to API keys in your application.
The starter kit ships with four example scopes:
readwritedeleteadminReplace these with scopes that reflect your own access model. For example, a billing platform might use customers, subscriptions, and invoices, while an AI product might use inference, training, and admin.
API_KEY_SCOPE_DESCRIPTIONS controls the human-readable labels shown in the UI.
Every scope defined in API_KEY_SCOPES must have a matching description.
API_KEY_MAX_PER_USER controls how many active API keys a single user may create. 20 is a default and it can and must be changed to suit your business logic.
The limit is enforced server-side and cannot be bypassed by client requests.
API_KEY_EXPIRY_OPTIONS controls which expiration periods appear in the key creation dialog.
export const API_KEY_EXPIRY_OPTIONS = [
{ label: "30 days", days: 30 },
{ label: "90 days", days: 90 },
{ label: "1 year", days: 365 },
{ label: "Never", days: null },
]Add, remove, or rename options to match your security requirements.
API_KEY_PREFIX defines the string prepended to every generated key.
Examples:
Using a recognizable prefix makes keys easier to identify during debugging, support requests, and incident response.
Key creation is handled by CreateKeyDialog on the client and actions/create-api-key.ts on the server.
The user opens the dialog, names the key, and picks an expiry from
API_KEY_EXPIRY_OPTIONS: 30 days, 90 days, 1 year, or never.
createApiKey() first counts the user’s existing keys and rejects the
request if it would exceed API_KEY_MAX_PER_USER.
auth.api.createApiKey() generates the actual key through Better Auth’s
plugin, using the configured expiry.
A short, unique preview is derived from the generated key and stored in
metadata, since the database’s own start column only holds the
configured prefix, not anything unique to this particular key.
An api_key_created audit event is written with the key’s name, expiry, and
preview.
The full key is returned to the dialog and shown to the user exactly once.
<DialogDescription>
Copy your API key now. You won’t be able to see it again.
</DialogDescription>After the dialog closes, the raw key is gone from the client. What persists in the database is the hashed or otherwise non-reversible key column value, plus the start and prefix fields used to identify the key in a list without exposing it again. The application has no way to show a created key a second time, by design.
start, as stored by Better Auth’s plugin, only contains the configured prefix, the same handful of characters for every key a given app generates. That is not enough to tell two keys apart in a list. createApiKey() works around this by slicing six characters out of the freshly generated key, appending them to the prefix, and saving that combined string into the key’s metadata field.
const prefix = created.prefix ?? ""
const uniquePart = created.key.slice(prefix.length, prefix.length + 6)
await auth.api.updateApiKey({
body: {
keyId: created.id,
metadata: { preview: `${prefix}${uniquePart}` },
userId: user.id,
},
headers: h,
})KeyRow reads this back out, falling back to prefix alone or the lowercased brand name if no preview was ever stored.
const meta = apiKey.metadata ? JSON.parse(apiKey.metadata) : null
const preview =
meta?.preview ?? apiKey.prefix ?? siteConfig.brand.name.toLowerCase()The rest of the key is rendered as a fixed run of block characters, not asterisks or a literal truncation of stored data, since the full key is never stored or retrievable in the first place.
config/api-keys.ts defines the scopes available in your product.
export const API_KEY_SCOPES = ["read", "write", "delete", "admin"] as const
export const API_KEY_SCOPE_DESCRIPTIONS: Record<ApiKeyScope, string> = {
read: "Read-only access to resources",
write: "Create and update resources",
delete: "Delete resources",
admin: "Full access including sensitive operations",
}This file is the one place you are expected to edit. The four example scopes are a starting point, not a fixed contract. Rename or replace them to match your own product’s access model.
What Oolvay does not do is enforce any of this for you. The create flow currently submits an empty scope list regardless of what is defined in API_KEY_SCOPES, and no API route in the starter kit reads a key’s permissions column to authorize a request. KeyRow is already built to display whatever scopes a key carries, badges rendered from the permissions field, but nothing populates that field yet.
const scopes = apiKey.permissions
? Object.keys(JSON.parse(apiKey.permissions))
: []Connecting the two, letting a user pick scopes at creation time, and checking those scopes against incoming requests in your own API routes, is the part left for you to build. The scaffolding exists so you are not starting from an empty schema and an empty page.
Revocation happens entirely from KeyRow, with a confirmation step required first.
action={async () => {
const res = await authClient.apiKey.delete({ keyId: apiKey.id })
if (res?.error) return { error: res.error }
await logApiKeyRevoked(apiKey.id)
router.refresh()
return { error: null }
}}The deletion call goes directly from the client to Better Auth, not through one of your own server actions first. Only after that call succeeds does logApiKeyRevoked() write the api_key_revoked audit event. This is the opposite order from account deletion elsewhere in the application, where the audit event is written before the underlying removal is attempted. Here, a failed authClient.apiKey.delete() call means no audit record is written at all, and the key remains active. A successful delete with a subsequent audit-logging failure is also possible, in which case the key is gone but the audit trail does not reflect it, since logApiKeyRevoked() only logs to Sentry on its own failure rather than retrying.
The apikey table, defined in db/schemas/api-key-schema.ts, includes several columns related to per-key rate limiting and quota refill.
| Column | Purpose, as named |
|---|---|
rateLimitEnabled | Whether rate limiting applies to this key |
rateLimitTimeWindow | The window length, in milliseconds, defaulting to 86400000, 24 hours |
rateLimitMax | The maximum requests allowed within that window |
requestCount | Requests made so far |
remaining | Requests left, if applicable |
refillInterval, refillAmount, lastRefillAt | A separate quota-replenishment mechanism |
None of the files reviewed for this page read or write these columns. They are not surfaced anywhere in /developer/api-keys, and no server action here inspects them. They are most likely populated and consumed internally by Better Auth’s API key plugin rather than by this application’s own code, but that has not been confirmed against the plugin’s source. Treat these columns as present in the schema and possibly active at the plugin level, not as a feature this page’s UI exposes or that your own routes can rely on without separately verifying how the plugin uses them.
Since API key creation and revocation directly affect how external systems authenticate against your application, both actions are recorded in the audit log. These events provide an administrative trail for security reviews, incident investigations, compliance requirements, and operational troubleshooting.
These two audit events are api_key_created and api_key_revoked.
The metadata for api_key_created includes the key’s name, expiry, requested scopes, and preview string. But the metadata for api_key_revoked includes only the key’s ID.