apikey

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

README

github.com/ubgo/auth/plugin/apikey

Gives users long-lived keys with scopes for scripts and integrations, sent as Authorization: Bearer ubk_... in place of a session, and checked by the app's own routes; each key can be renamed, paused, re-expired, labelled with metadata, rate-limited and given a usage quota.

Install

go get github.com/ubgo/auth

The package is part of the core module github.com/ubgo/auth and adds no dependency beyond the core's own (github.com/ubgo/sqlb, golang.org/x/crypto).

Wire it

From examples/api-keys-and-jwt:

a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(
		email.New(email.Login(), email.Recover(), email.WithMailer(m)),
		password.New(),
		apikey.New(apikey.Scopes("orders:read", "orders:write")),
	),
)

forKeys := func(scope string, h http.HandlerFunc) http.Handler {
	return authhttp.Middleware(a)(authhttp.RequireUser(authhttp.RequireScope(scope)(h)))
}
mux.Handle("/auth/", authhttp.Handler(a, authhttp.Prefix("/auth")))
mux.Handle("GET /api/orders", forKeys("orders:read", o.list))

The plugin needs a sign-in method (any plugin such as password): keys are created by a signed-in user. In app code, auth.HasScope(ctx, scope) and auth.CurrentScopes(ctx) read the key's scopes; authhttp.RequireScope answers 403 to a browser session or a key without the scope.

auth.New fails when:

Condition Error
Neither Scopes nor WithPermissions was passed ErrNoScopes: "apikey: declare what a key may carry: scopes with apikey.Scopes, permissions with apikey.WithPermissions, or both"
A declared scope is empty or contains a space, tab or newline ErrBadScope: "apikey: a scope is non-empty and contains no whitespace"
The prefix is shorter than 3 characters (minPrefixLength) or does not end with _ ErrBadPrefix: "apikey: a prefix is at least three characters and ends with _"
MaxKeys or NameLength is below 1, NameBounds has a negative bound, a maximum below the minimum or a minimum with no maximum, HintLength is negative or above maxHintLength (8), or WithExpiry has a negative field, a minimum above the maximum, or a default outside the bounds ErrBadLimits: "apikey: limits are positive and the expiry default lies within its bounds"
DefaultLimits pairs RateMax without RateWindow or RefillAmount without RefillInterval, or holds a negative value ErrBadLimits wrapping ErrBadLimitsValue
Another token resolver's prefix overlaps this one auth.ErrBadOperation

Options

Option What it does Default
Scopes(scopes ...string) Every scope a key may carry; a request for another is refused none (required unless WithPermissions is set)
Prefix(prefix string) The start of every key, so secret scanners recognize a leaked one and the core routes it here DefaultPrefix (ubk_)
MaxKeys(n int) Keys one account may hold; the next create is ErrTooMany MaxKeysPerUser (50)
RequireName() A create without a name is ErrNameRequired off: names are optional
NameLength(runes int) Names longer than this are cut, not refused 64 (maxNameLength)
NameBounds(min, max int) A name shorter than min or longer than max runes is refused (ErrBadName) at create and rename, instead of being cut off: names are cut to NameLength
HintLength(n int) How many characters after the prefix the hint keeps, at most 8; 0 keeps the prefix alone 4 (hintExtra)
Generate(fn func() string) Replaces the random part of a new key (after the prefix) with fn's result, for a particular alphabet or length; at least 22 characters (128 bits) or the create fails with ErrShortSecret auth.NewToken: 32 random bytes, base64url
WithExpiry(Expiry{MinDays, MaxDays, DefaultDays}) Bounds and defaults expiresInDays, and bounds expiresIn (seconds) measured in days: outside the bounds is ErrExpiryOutOfRange; with MaxDays set, "never" (0) is refused too; DefaultDays fills an empty request no bounds, no default, 0 means never
AllowMetadata() Create and update may store a JSON object of at most maxMetadataLength (4096) bytes with each key, returned in Key.Metadata off: metadata in a request is ErrMetadataDisabled
DefaultLimits(Limits{...}) The rate limit and quota every key made through the create operation gets; CreateFor and SetLimits set them per key from Go none
Configuration(id, opts...) A key configuration beside the default one (such as live with Prefix("sk_live_") and test with Prefix("sk_test_")): its own prefix, scopes, limits, expiry and name rules, sharing the one table through the config_id column. It starts from the plugin's options and applies opts on top (Scopes adds to the inherited list). An id is at most 32 letters, digits, _ or -, unique and not default (ErrBadConfig). Requests name it with configId; none means DefaultConfig (default); an unknown one is ErrUnknownConfig (better-auth falls back to the default instead) the default configuration only
AllowKeyPrefix(max) A create may send prefix, up to max letters, digits, _ or - (0 means 32), placed after the configuration's prefix, which stays first because it routes the key off: a prefix in a create is ErrBadKeyPrefix
DefaultScopes(scopes...), DefaultScopesFunc(fn) The scopes of a key created without a scopes field (an explicit empty list stays empty); the function form decides per user, and an undeclared scope it returns fails the create with ErrUnknownScope none
PreCheck(fn) fn(ctx, key) bool runs before the database lookup; false refuses the key like an unknown one. fn sees the whole key and must never log it none
DeleteSpentKeys() An expired key, or an exhausted key with no refill, is deleted when next presented (still refused the same way) off: the row stays until auth.Sweep
DistinctErrors() (plugin-wide) A refused key answers its own 401 code (apikey.key_disabled, apikey.key_expired, apikey.invalid_owner, apikey.owner_blocked; apikey.invalid_key from Verify). Risk: whoever holds a stolen key learns its state, and a refused key fails even a route that serves anonymous callers off: all are auth.signed_out
RejectUnknownKeys() (plugin-wide) A bearer token with a key prefix that is no live key answers 401 apikey.invalid_key (better-auth's INVALID_API_KEY) on every route instead of passing through signed out; without DistinctErrors every refusal, a banned owner's included, is that same code. auth.New refuses it (ErrPrefixNotDistinct) unless every configuration's prefix holds a character outside base64url, such as sk.live_, so no core session token (base64url) can start with a prefix and be refused as a key. Cost: a bad key fails even a route that serves anonymous callers off: an unknown key passes through to the sessions
WithCache(c, ttl) (plugin-wide) Puts a Cache (NewMemoryCache(), or any shared store) in front of the key lookup for ttl (0 is 1 minute, at most 15). Revoke, update and SetLimits drop the entry; limits still run as guarded writes. A key revoked on another instance with an in-process cache keeps working until its entry expires no cache
OrganizationOwned() The configuration's keys belong to an organization, not a user (better-auth's references: "organization"). A create names it with organizationId; create, list/get, update and revoke need the caller's organization role to grant apiKey create, read, update or delete, and the organization's creator role passes every one. The resource is ResourceAPIKey (apiKey), and Statements() is its vocabulary to merge into the organization's access control. Such a key never signs anyone in: a bearer request with it is signed out (apikey.organization_key under DistinctErrors) before its counters move; the app checks it with Verify, which reports Verification.OrganizationID. Its prefix may not overlap a user-owned configuration's (ErrBadConfig), and it needs WithOrganizations (ErrOrganizationsRequired from auth.New) off: keys belong to users
WithOrganizations(o) (plugin-wide) How the plugin asks an organization about a member: o.APIKeyPermission(ctx, orgID, userID, action) (member, allowed bool, err error). The core cannot import plugin/organization (its own module), so it asks through this interface: *organization.Plugin implements it, and an app with organizations of its own writes the method or uses OrganizationsFunc none
WithPermissions(c) A key may carry permissions, a resource-to-actions set (apikey.Permissions, better-auth's permission object) checked by the apikey.PermissionChecker c (Check, Grants; a failed Check is ErrBadPermissions). The built-in checker is apikey.Vocabulary{"orders": {"read", "write"}}; accessapikey.New(ac) from the plugin/access module uses an access control instead, and an app may plug in its own model. Set from Go only (CreateRequest.Permissions, which JSON never fills, CreateFor, SetPermissions), as better-auth makes them a server-only property; checked with VerifyPermissions or Authorize. A key whose stored permissions name something the checker no longer declares is refused every check (its stored permissions fail Check); called directly, Vocabulary.Grants ignores such a held entry and denies a check that names it. Scopes stay beside them off: a permission on a key is ErrPermissionsOff
DefaultPermissions(perms), DefaultPermissionsFunc(fn) The permissions of a key created without any; the function form decides per owner (the user id, or the organization id of an organization key). DefaultPermissions(nil) inside a Configuration clears the inherited default none
DeferUpdates() (plugin-wide) The last_used_at/updated_at stamp and DeleteSpentKeys' delete run through the core's background runner after the check; the rate-limit and quota writes never do off: done on the request
StampEveryUse() (plugin-wide) Every use writes last_used_at and updated_at with a write guarded on the key's id, on the request (never deferred): a key deleted while its request was checked is refused like an unknown one and its cache entry dropped, even a key without limits; a failing write fails the request. better-auth's verify stamping updatedAt every call last_used_at at most once a minute, a failure only logged, no write for a key without limits

Limits is the per-key rate limit and quota: RateMax requests per RateWindow (a fixed window counted from the first request after the previous window ended), and Remaining uses (nil for unlimited) reset to RefillAmount every RefillInterval since the last refill or creation. RateMax pairs with RateWindow and RefillAmount with RefillInterval; windows and intervals are whole seconds; on the wire they are rateWindowSeconds and refillIntervalSeconds. The zero value is no limit, which every key made before migration 0002 has.

Routes and Go API

Paths are under the mount prefix (/auth in the example). A Query is served as GET, a Mutation as POST.

Route Go API Requirement Rate limit
POST /apikey/create {"configId","name","scopes","prefix","expiresInDays","expiresIn","metadata","organizationId"} (expiresIn in seconds, better-auth's unit, or expiresInDays, not both) (organizationId only under an OrganizationOwned configuration, where it is required) (*Plugin).Create(ctx, CreateRequest) (CreateResult, error) FreshSignIn none
GET /apikey/list?configId=live&organizationId=org_…&limit=20&offset=40&sortBy=name&sort=desc (each optional; organizationId lists that organization's keys, needing apiKey read there, and the caller's own list never shows an organization's keys) (*Plugin).List(ctx) ([]Key, error) for the whole list oldest first; (*Plugin).ListKeys(ctx, ListRequest) for a page: ListRequest (limit, offset, sortBy one of createdAt, name, lastUsedAt, expiresAt, sort asc or desc) answers ListResult {"keys","total","limit","offset"}; a bad value is ErrBadList SignedIn none
GET /apikey/get?id=key_…&configId=… (configId optional; another configuration's key is ErrNotFound) (*Plugin).Get(ctx, id) (Key, error); another user's key or an unknown id is ErrNotFound SignedIn none
POST /apikey/revoke {"id","configId"} (*Plugin).Revoke(ctx, id) error SignedIn none
POST /apikey/update {"id","configId","name","expiresInDays","expiresIn","disabled","metadata","scopes"} (each optional; scopes replaces the key's scopes, an empty list clears them; the key's own configuration's rules apply) (*Plugin).Update(ctx, UpdateRequest) (Key, error) FreshSignIn none
Go only (*Plugin).CreateFor(ctx, userID, CreateRequest, Limits): a key for any user, no session needed, metadata accepted without AllowMetadata none (app code) none
Go only (*Plugin).SetLimits(ctx, id, Limits): replaces a key's limits and resets its counters; a refillable quota with no Remaining starts at RefillAmount (here, in CreateFor and in DefaultLimits) none (app code) none
Go only (*Plugin).Verify(ctx, secret, scopes...): checks a key received outside the Authorization header (a webhook signature, a websocket message) through the core's own resolution, counting the use against its limits, and answers a Verification {Valid, Reason, Key, UserID, OrganizationID} (OrganizationID set, UserID empty, for an organization-owned key); Reason is auth.ErrSignedOut, auth.ErrRateLimited, ErrUsageExceeded or ErrScopeMissing (or a DistinctErrors code), and the error return is only an infrastructure failure none (app code) none
Go only (*Plugin).VerifyFor(ctx, configID, secret, scopes...): Verify for one configuration; a key of another is refused before its counters move, an unknown configuration is Reason ErrUnknownConfig none (app code) none
Go only (*Plugin).VerifyPermissions(ctx, secret, perms): Verify that also requires the key's permissions to grant every action of perms (Reason ErrPermissionDenied otherwise, including a key with none) none (app code) none
Go only (*Plugin).Authorize(ctx, perms): the current request's key must grant perms; ErrPermissionDenied otherwise or for a request made without a key. Guard a route with authhttp.RequireFunc(func(r) error { return ak.Authorize(r.Context(), perms) }) none (app code) none
Go only (*Plugin).SetPermissions(ctx, id, perms): replaces a key's permissions (nil clears them) and drops its cache entry none (app code) none
Go only (*Plugin).DeleteOrganizationKeys(ctx, orgID): deletes an organization's keys and their cache entries; call it from organization.Hooks.AfterDeleteOrg, since no foreign key does none (app code) none
Any app route, Authorization: Bearer <key> (*Plugin).ResolveToken(ctx, token), called by the core none none

Update changes only the fields present (ErrNoChanges when none is); expiresInDays and expiresIn follow the create rules, disabled: true pauses the key (refused like an unknown one until disabled: false), and metadata replaces the object. On resolve, after the hash lookup and the expiry and disabled checks, the key's limits are enforced with guarded UPDATEs run outside the caller's transaction, so a burst never exceeds RateMax and Remaining never goes below zero; a refill is a compare-and-swap on last_refill_at, so one request resets the count and the others spend from it. The plugin is an auth.Sweeper: auth.Sweep (and auth sweep from authcli) deletes keys past their expiry, which were already refused on use. All four operations refuse a request made with a key (auth.ErrAPIKeyRefused): a script cannot mint, list or revoke keys, change the password or read the session list. List returns keys oldest first, and keys made in the same instant in id order, so every engine returns the same order. CreateResult.Secret is the key, shown this once. ExpiresInDays 0 means the key never expires, unless WithExpiry sets a default or a maximum. The name is cut to 64 runes (maxNameLength); repeated scopes are stored once, sorted. An account holds at most MaxKeysPerUser (50) keys. last_used_at is written at most once per usedEvery (1 minute) per key, or on every use under StampEveryUse.

Tables and columns

The plugin owns auth_api_key (TableNameKey, alias ak), created by migration 0001_api_key (owner apikey):

Column Constant Meaning
id ColID Primary key, key_...
user_id ColUserID Owner; deleted with the user by a.DeleteUser (declared in UserTables; no foreign key)
name ColName The owner's label
hint ColHint The prefix plus 4 characters (hintExtra; HintLength changes it, 0 to 8), shown in lists; never enough to use
key_hash ColKeyHash SHA-256 of the key, UNIQUE; the key itself is never stored
scopes ColScopes Scopes joined by a space
created_at ColCreatedAt When created
last_used_at ColLastUsedAt Last use, NULL until first use
expires_at ColExpiresAt Expiry, NULL for never
disabled_at ColDisabledAt When the owner paused the key; NULL while enabled (migration 0002_api_key_limits)
metadata ColMetadata The owner's JSON object, NULL when none
rate_max, rate_window_seconds ColRateMax, ColRateWindow The per-key rate limit; 0 means none
rate_count, rate_window_start ColRateCount, ColRateWindowStart The current window's counter and start (NULL before the first request)
remaining ColRemaining Uses left; NULL means unlimited
refill_amount, refill_interval_seconds, last_refill_at ColRefillAmount, ColRefillInterval, ColLastRefillAt The refill rule and when it last ran (NULL means the clock started at created_at)
config_id ColConfigID The key's configuration; default for rows made before migration 0003_api_key_config
prefix ColPrefix The key's full prefix in clear (the configuration's, then its own under AllowKeyPrefix); NULL before 0003
updated_at ColUpdatedAt When the key last changed: create, update, SetLimits, a counter write, or the once-a-minute use stamp; NULL before 0003
organization_id ColOrganizationID The owning organization of an organization-owned key, whose user_id is then "", so no user-side query and no DeleteUser ever reaches it; NULL for a user's key (migration 0004_api_key_org)
permissions ColPermissions The key's permissions as a JSON object of resource to actions, NULL for none (0004)

Index auth_api_key_user_idx on user_id, auth_api_key_config_idx on config_id, and auth_api_key_org_idx on organization_id. Typed columns are exported as KeyID, KeyUserID, and so on. These columns are not in the core's stable-column contract (stability_test.go covers auth_user, auth_identity and auth_session only), so treat them as readable but not frozen across minor versions.

Errors

Variable Code HTTP When
ErrUnknownScope apikey.unknown_scope 400 Create or update asked for a scope not declared with Scopes
ErrTooMany apikey.too_many 409 The account already holds MaxKeysPerUser keys (50)
ErrNotFound apikey.not_found 404 Revoke, update or get of an unknown key or of another user's key (the same answer, so ids cannot be probed)
ErrNameRequired apikey.name_required 400 RequireName is set and the name is blank
ErrExpiryOutOfRange apikey.expiry_out_of_range 400 expiresInDays or expiresIn is negative, outside WithExpiry's bounds (seconds compared as a duration of days), longer than a time.Duration holds (about 292 years), or 0 while MaxDays is set
ErrExpiryConflict apikey.expiry_conflict 400 A create or update with both expiresIn and expiresInDays
ErrNoChanges apikey.no_changes 400 An update with no field set
ErrBadList apikey.bad_list 400 A list with a limit or offset that is not a non-negative integer, an unknown sortBy or sort
ErrBadName apikey.bad_name 400 NameBounds is set and the name is outside them, at create or rename
ErrScopeMissing apikey.missing_scope 403 Verify was asked for a scope the key lacks (Go only; never answered to a client by this plugin)
ErrShortSecret none; answered over HTTP as auth.internal 500 Generate returned fewer than 22 characters; an app bug
ErrMetadataDisabled apikey.metadata_disabled 400 Metadata in a create or update without AllowMetadata
ErrBadMetadata apikey.bad_metadata 400 Metadata that is not a JSON object or is over 4096 bytes
auth.ErrRateLimited (via auth.RateLimitedFor) auth.rate_limited 429 A request with a key past RateMax in the current window; Retry-After says when the window ends
ErrUsageExceeded apikey.usage_exceeded 429 A request with a key whose Remaining is zero and no refill due
ErrBadLimitsValue none none CreateFor or SetLimits with limits that break the pairing rules; wrapped in ErrBadLimits from auth.New for DefaultLimits
ErrUnknownConfig apikey.unknown_config 400 A request (or CreateFor, VerifyFor) naming a configuration that does not exist
ErrBadKeyPrefix apikey.bad_prefix 400 A create's prefix without AllowKeyPrefix, longer than its bound, or with other characters than letters, digits, _ and -
ErrInvalidKey apikey.invalid_key 401 DistinctErrors: Verify of an unknown key, one refused by PreCheck, or one of another configuration than VerifyFor named. RejectUnknownKeys: also a bearer token with the key prefix that is no live key, on every route (and every refusal, without DistinctErrors)
ErrKeyDisabled apikey.key_disabled 401 DistinctErrors only: a paused key
ErrKeyExpired apikey.key_expired 401 DistinctErrors only: a key past its expiry
ErrKeyOwnerGone apikey.invalid_owner 401 DistinctErrors only: a key whose user no longer exists
ErrOwnerBlocked apikey.owner_blocked 401 DistinctErrors only: a key whose user exists but may not sign in (banned, or refused by an app's SessionChecker); better-auth's USER_BANNED
ErrOrganizationIDRequired apikey.organization_id_required 400 A create under an OrganizationOwned configuration without organizationId
ErrNotOrganizationConfig apikey.not_organization_config 400 A create naming an organization under a configuration whose keys belong to users
ErrNotOrganizationMember apikey.not_organization_member 403 A create or list naming an organization the caller is not a member of. Get, update and revoke of such an organization's key answer ErrNotFound instead, so a key id cannot be probed
ErrOrganizationPermission apikey.organization_permission 403 A member whose role does not grant the apiKey action asked for
ErrOrganizationKey apikey.organization_key 401 DistinctErrors only: an organization-owned key presented as a bearer credential
ErrPermissionsOff apikey.permissions_off 400 Permissions on a key of a configuration without WithPermissions
ErrBadPermissions apikey.bad_permissions 400 Permissions naming a resource or action WithPermissions does not declare; from auth.New for DefaultPermissions
ErrPermissionDenied apikey.permission_denied 403 VerifyPermissions or Authorize asked for what the key's permissions do not grant (Go only)
ErrNoScopes, ErrBadScope, ErrBadPrefix, ErrBadLimits, ErrBadConfig, ErrBadCacheTTL, ErrOrganizationsRequired none none Configuration, returned by auth.New

By default an unknown, revoked, expired or disabled key, or one belonging to a banned user, is simply signed out: auth.ErrSignedOut (401) on routes that need a user. Under DistinctErrors the disabled, expired and ownerless cases answer their own code instead; an unknown key over HTTP still leaves the request signed out, because a session token may start with a key prefix by chance and is then looked up as a session. RejectUnknownKeys answers it apikey.invalid_key instead, once every prefix holds a character no session token has.

Security notes

Property Pinned by
A key acts in app routes with its scopes, and every auth operation refuses it TestAPIKey_ActsInAppRoutesNotInAuthOperations
A key sent in the session cookie is not trusted TestAPIKey_ActsInAppRoutesNotInAuthOperations
Banning a user stops their keys; revoked and expired keys are refused TestAPIKey_ActsInAppRoutesNotInAuthOperations, TestTokensAPIKey_Lifecycle
Another user's key cannot be revoked, and looks unknown TestTokensAPIKey_Lifecycle
Overlapping token prefixes are refused at wiring TestNew_RefusesOverlappingTokenPrefixes
A failed last_used_at write signs nobody out and is logged TestTokensAPIKey_RecordUseFailureIsOnlyLogged
An Auth without a database reports auth.ErrNoDatabase instead of panicking TestNoDatabase_ReportsInsteadOfPanicking
Only the key's SHA-256 and a short hint are stored: no column of auth_api_key holds the key or its random part TestTokensAPIKey_OnlyTheHashIsStored
Every database failure on create, list, revoke and resolve reaches the caller TestTokensAPIKey_Faults
A paused key is refused like an unknown one; another user's key cannot be updated TestTokensAPIKey_Update
A burst of requests never exceeds RateMax, and Remaining never goes below zero, on Postgres and MySQL TestTokensAPIKey_LimitsHoldUnderConcurrency
The window reopens, the quota refills once per interval, and a non-refilling key runs out TestTokensAPIKey_RateLimitAndQuota
Get and the list page never show another user's key, and the total counts only the caller's TestTokensAPIKey_GetAndListPaging
Verify refuses an unknown, foreign-prefix, disabled, rate-limited or exhausted key and a missing scope with the same reasons a bearer request gets, and counts the use TestTokensAPIKey_Verify
New scopes take effect on the next request; a short Generate result mints nothing; a refillable quota starts full TestTokensAPIKey_ScopesNamesHintAndGenerator
Every database failure on get, a list page, a scope update and Verify reaches the caller TestTokensAPIKey_Round3Faults
Every database failure on update, CreateFor, SetLimits and each counter write reaches the caller TestTokensAPIKey_LimitFaults
Configurations keep their own prefix, scopes, cap and limits in one table; VerifyFor refuses another configuration's key without spending its quota TestTokensAPIKey_Configurations
DistinctErrors names each refusal on Verify and on the wire; without it every refusal stays auth.signed_out and an open route serves the caller anonymously TestTokensAPIKey_PreCheckAndDistinctErrors, TestTokensAPIKey_DefaultStaysSilent
A cache hit spares the lookup, revoke and update drop the entry at once, the limits hold through stale cached counters, and a key deleted under a cached entry is refused rather than counted TestTokensAPIKey_Cache
DeleteSpentKeys deletes only keys that can never work again; without it they stay listed TestTokensAPIKey_DeleteSpentKeys, TestTokensAPIKey_DefaultKeepsSpentKeys
Every database failure on the round-6 paths reaches the caller, except the logged stamp and delete TestTokensAPIKey_Round6Faults

Limits, honestly: the database keeps a fast SHA-256 of the key rather than a slow password hash, which is safe because a key carries 256 random bits. The library carries scopes but never enforces them: each app route must check auth.HasScope or use authhttp.RequireScope. The key operations have no rate limit of their own; the per-account cap bounds creation. A per-key rate limit costs one or two UPDATEs per request on top of the lookup; a key with no limits writes nothing but the once-a-minute last_used_at. Organization-owned keys never act as a user: they are for Verify, as in better-auth. Permissions are checked only where the app calls VerifyPermissions or Authorize, exactly as scopes are checked only where it calls auth.HasScope. With WithCache, revocation is immediate only on the instance that revoked (or everywhere with a shared store); elsewhere it waits for the TTL.

Example

Documentation

Overview

Package apikey gives users long-lived keys for scripts and integrations: "Authorization: Bearer ubk_…" in place of a session.

A key acts for its user in the app's own routes, with the scopes it was created with; the app checks them with auth.HasScope. The auth operations themselves refuse a key (auth.ErrAPIKeyRefused): a script cannot change the password or read the session list. A key is shown once, at creation; the database keeps its SHA-256, which is safe for a 256-bit random value.

Index

Constants

View Source
const (
	EndpointCreate auth.EndpointID = "apikey.create"
	EndpointList   auth.EndpointID = "apikey.list"
	EndpointRevoke auth.EndpointID = "apikey.revoke"
	EndpointUpdate auth.EndpointID = "apikey.update"
	EndpointGet    auth.EndpointID = "apikey.get"
)

Operation ids.

View Source
const (
	//ds:def id=tablenamekey-4q22c5ze owner=@khanakia stability=stable
	TableNameKey  = "auth_api_key"
	TableAliasKey = "ak"
	//ds:def id=colid-wve5u4tk owner=@khanakia stability=stable
	ColID = "id"
	//ds:def id=coluserid-d85t7bwn owner=@khanakia stability=stable
	ColUserID = "user_id"
	//ds:def id=colname-buussu3v owner=@khanakia stability=stable
	ColName = "name"
	// ColHint is the first characters of the key, shown in lists so a user
	// can tell keys apart; never enough to use.
	//ds:def id=colhint-685gvhrx owner=@khanakia stability=stable
	ColHint = "hint"
	//ds:def id=colkeyhash-n9ne59xk owner=@khanakia stability=stable
	ColKeyHash = "key_hash"
	//ds:def id=colscopes-nuk5dts4 owner=@khanakia stability=stable
	ColScopes = "scopes"
	//ds:def id=colcreatedat-kav6jpyq owner=@khanakia stability=stable
	ColCreatedAt = "created_at"
	//ds:def id=collastusedat-5b23p9ng owner=@khanakia stability=stable
	ColLastUsedAt = "last_used_at"
	//ds:def id=colexpiresat-hf6aw2fc owner=@khanakia stability=stable
	ColExpiresAt = "expires_at"
)

The plugin's table.

View Source
const (
	SortByCreatedAt  = "createdAt"
	SortByName       = "name"
	SortByLastUsedAt = "lastUsedAt"
	SortByExpiresAt  = "expiresAt"
	SortAsc          = "asc"
	SortDesc         = "desc"
)

ListRequest sort fields and directions. They are the JSON names of the Key fields they sort by, so a client sorts by the name it displays.

View Source
const (
	// DefaultCacheTTL is used when WithCache gets 0: short, because a key
	// revoked on another instance (or while a request was filling the cache)
	// keeps working until its entry expires.
	//ds:def id=defaultcachettl-w8kc3nzq owner=@khanakia stability=stable
	DefaultCacheTTL = time.Minute
	// MaxCacheTTL is the longest WithCache accepts: past it, "revoke" would
	// stop meaning "now" in any useful sense.
	//ds:def id=maxcachettl-j4rx7mbs owner=@khanakia stability=stable
	MaxCacheTTL = 15 * time.Minute
)

Cache TTL bounds.

View Source
const (
	// ColConfigID is the key's configuration id; rows made before 0003 get
	// DefaultConfig from the column default.
	//ds:def id=colconfigid-f6np2qwd owner=@khanakia stability=stable
	ColConfigID = "config_id"
	// ColPrefix is the key's full prefix in clear; NULL before 0003.
	//ds:def id=colprefix-z3hk8vmt owner=@khanakia stability=stable
	ColPrefix = "prefix"
	// ColUpdatedAt is when the key last changed; NULL before 0003.
	//ds:def id=colupdatedat-u7cy4rbn owner=@khanakia stability=stable
	ColUpdatedAt = "updated_at"
)

Columns added by migration 0003 (round 6, 2026-10-06): the configuration a key belongs to, its prefix in clear, and when it last changed.

View Source
const (
	// ColDisabledAt is when the owner disabled the key; NULL for an enabled
	// key. A disabled key is refused like an unknown one and re-enabled by
	// update, which revoke-and-recreate could not offer.
	//ds:def id=coldisabledat-y7kp3qvn owner=@khanakia stability=stable
	ColDisabledAt = "disabled_at"
	// ColMetadata is the owner's JSON object, NULL when none was given.
	//ds:def id=colmetadata-d4xw8mrt owner=@khanakia stability=stable
	ColMetadata = "metadata"
	// ColRateMax requests per ColRateWindow; 0 means no per-key rate limit.
	//ds:def id=colratemax-b2nq6ksj owner=@khanakia stability=stable
	ColRateMax = "rate_max"
	//ds:def id=colratewindow-g9tc5lvz owner=@khanakia stability=stable
	ColRateWindow = "rate_window_seconds"
	// ColRateCount and ColRateWindowStart are the fixed window's counter and
	// start; the start is NULL until the first request.
	//ds:def id=colratecount-m3fh7wpd owner=@khanakia stability=stable
	ColRateCount = "rate_count"
	//ds:def id=colratewindowstart-r6jd2bxn owner=@khanakia stability=stable
	ColRateWindowStart = "rate_window_start"
	// ColRemaining is the uses left; NULL means unlimited.
	//ds:def id=colremaining-k8sv4czq owner=@khanakia stability=stable
	ColRemaining = "remaining"
	// ColRefillAmount resets ColRemaining every ColRefillInterval seconds;
	// 0 means no refill.
	//ds:def id=colrefillamount-p5wm9tyh owner=@khanakia stability=stable
	ColRefillAmount = "refill_amount"
	//ds:def id=colrefillinterval-x2bk7nqs owner=@khanakia stability=stable
	ColRefillInterval = "refill_interval_seconds"
	// ColLastRefillAt is when the quota was last refilled; NULL means never,
	// and the refill clock then starts at created_at.
	//ds:def id=collastrefillat-v4gz8mcj owner=@khanakia stability=stable
	ColLastRefillAt = "last_refill_at"
)

Columns added by migration 0002 (round 2, 2026-10-02): the disable flag, metadata, the per-key rate limit and the usage quota.

View Source
const (
	ActionCreate = "create"
	ActionRead   = "read"
	ActionUpdate = "update"
	ActionDelete = "delete"
)

The actions on ResourceAPIKey, better-auth's API_KEY_PERMISSIONS: create checks a new organization key, read its get and list, update its update, delete its revoke.

View Source
const (
	// ColOrganizationID is the organization that owns the key. A row has
	// one of two shapes: a user's key (user_id the owner, organization_id
	// NULL), or an organization's key (organization_id the owner, user_id
	// ""). The empty user_id is deliberate: every user-side query filters
	// user_id by the caller, so an organization key is out of reach of them
	// all, and DeleteUser (which deletes by user_id) leaves it alone.
	//ds:def id=colorganizationid-q3vx8nkt owner=@khanakia stability=stable
	ColOrganizationID = "organization_id"
	// ColPermissions is the key's permissions as a JSON object of resource
	// to actions, or NULL for none.
	//ds:def id=colpermissions-h7cw4mzp owner=@khanakia stability=stable
	ColPermissions = "permissions"
)

Columns added by migration 0004 (round 36, 2026-10-07).

View Source
const DefaultConfig = "default"

DefaultConfig is the id of the configuration the plugin's own options describe. Keys made before configurations existed carry it (the column's default), and a request that names no configuration means it.

View Source
const DefaultPrefix = "ubk_"

DefaultPrefix starts every key, so a leaked key is recognizable by secret scanners and routed to this plugin instead of the session table.

View Source
const MaxKeysPerUser = 50

MaxKeysPerUser is the default cap on the keys one account holds, so a leaked session cannot mint keys without end; Create past the cap is ErrTooMany. Fifty is far above one per integration. MaxKeys changes it.

View Source
const PluginName = "apikey"

PluginName is the plugin's permanent identity.

View Source
const ResourceAPIKey = "apiKey"

ResourceAPIKey is the resource an organization's roles grant API-key actions on, better-auth's "apiKey": an app adds Statements to the access control its organization roles are built from and grants the actions per role (an admin all four, a member "read", in better-auth's example).

Variables

View Source
var (
	TableKey   = sqlb.Table{Name: TableNameKey, Alias: TableAliasKey}
	KeyID      = sqlb.Col[string](TableKey, ColID)
	KeyUserID  = sqlb.Col[string](TableKey, ColUserID)
	KeyName    = sqlb.Col[string](TableKey, ColName)
	KeyHint    = sqlb.Col[string](TableKey, ColHint)
	KeyHash    = sqlb.Col[[]byte](TableKey, ColKeyHash)
	KeyScopes  = sqlb.Col[string](TableKey, ColScopes)
	KeyCreated = sqlb.Col[time.Time](TableKey, ColCreatedAt)
	KeyUsed    = sqlb.Col[*time.Time](TableKey, ColLastUsedAt)
	KeyExpires = sqlb.Col[*time.Time](TableKey, ColExpiresAt)
)

Typed columns.

View Source
var (
	ErrUnknownScope = auth.NewError("apikey.unknown_scope", http.StatusBadRequest, "A scope in this request does not exist.")
	ErrTooMany      = auth.NewError("apikey.too_many", http.StatusConflict, "This account has too many keys. Revoke one first.")
	ErrNotFound     = auth.NewError("apikey.not_found", http.StatusNotFound, "Not found.")
	// ErrNoScopes is a configuration that declares neither scopes
	// (Scopes) nor permissions (WithPermissions): a key could then carry
	// nothing the app checks, so auth.New refuses it.
	ErrNoScopes = errors.New("apikey: declare what a key may carry: scopes with apikey.Scopes, permissions with apikey.WithPermissions, or both")
	// ErrBadScope is a configuration error: a declared scope that is empty or
	// contains whitespace, which the scopes column could not store apart.
	ErrBadScope = errors.New("apikey: a scope is non-empty and contains no whitespace")
	// ErrBadPrefix is a configuration error: a prefix too short for a secret
	// scanner to recognize, or one that does not end with "_".
	ErrBadPrefix = errors.New("apikey: a prefix is at least three characters and ends with _")
	// ErrBadLimits is a configuration error: MaxKeys, NameLength or
	// WithExpiry given values that would make every create fail.
	ErrBadLimits = errors.New("apikey: limits are positive and the expiry default lies within its bounds")
	// ErrNameRequired is a create without a name when RequireName is set.
	ErrNameRequired = auth.NewError("apikey.name_required", http.StatusBadRequest, "Give the key a name.")
	// ErrExpiryOutOfRange is an expiry outside the WithExpiry bounds,
	// including "never" when a maximum is set.
	ErrExpiryOutOfRange = auth.NewError("apikey.expiry_out_of_range", http.StatusBadRequest, "The expiry is outside what this app allows.")
	// ErrExpiryConflict is a request that sets both expiresIn (seconds)
	// and expiresInDays: two answers to one question, so neither is
	// guessed at.
	ErrExpiryConflict = auth.NewError("apikey.expiry_conflict", http.StatusBadRequest, "Give the expiry in seconds or in days, not both.")
	// ErrBadList is a ListRequest with a limit or offset that is not a
	// non-negative integer, an unknown sort field or direction.
	ErrBadList = auth.NewError("apikey.bad_list", http.StatusBadRequest, "The list options are not valid.")
	// ErrBadName is a name outside the NameBounds, at create or rename.
	ErrBadName = auth.NewError("apikey.bad_name", http.StatusBadRequest, "The name is too short or too long.")
	// ErrScopeMissing is a Verify whose key lacks a scope the caller
	// required; Go only, never answered to a client by this plugin.
	ErrScopeMissing = auth.NewError("apikey.missing_scope", http.StatusForbidden, "This key does not allow this.")
	// ErrShortSecret is a Generate function that returned fewer than
	// minSecretLength characters: a programming error, so create fails
	// rather than mint a guessable key.
	ErrShortSecret = errors.New("apikey: Generate returned a secret shorter than 22 characters")
)

Errors.

View Source
var (
	// ErrBadConfig is a configuration error: a Configuration id that is
	// empty, longer than 32 characters, made of other than letters, digits,
	// "_" and "-", repeated, or DefaultConfig; or a plugin-wide option inside
	// a Configuration.
	ErrBadConfig = errors.New("apikey: a configuration id is unique, at most 32 letters, digits, _ or -, not \"default\", and a configuration holds no plugin-wide option")
	// ErrUnknownConfig is a request naming a configuration that does not
	// exist.
	ErrUnknownConfig = auth.NewError("apikey.unknown_config", http.StatusBadRequest, "This key configuration does not exist.")
	// ErrBadKeyPrefix is a create with a prefix while AllowKeyPrefix is off,
	// or one longer than its bound or with other characters than letters,
	// digits, "_" and "-".
	ErrBadKeyPrefix = auth.NewError("apikey.bad_prefix", http.StatusBadRequest, "The key prefix is not allowed.")
)

Errors of configurations and per-key prefixes.

View Source
var (
	KeyConfig  = sqlb.Col[string](TableKey, ColConfigID)
	KeyPrefix  = sqlb.Col[*string](TableKey, ColPrefix)
	KeyUpdated = sqlb.Col[*time.Time](TableKey, ColUpdatedAt)
)

Typed columns of migration 0003.

View Source
var (
	KeyDisabledAt      = sqlb.Col[*time.Time](TableKey, ColDisabledAt)
	KeyMetadata        = sqlb.Col[*string](TableKey, ColMetadata)
	KeyRateMax         = sqlb.Col[int64](TableKey, ColRateMax)
	KeyRateWindow      = sqlb.Col[int64](TableKey, ColRateWindow)
	KeyRateCount       = sqlb.Col[int64](TableKey, ColRateCount)
	KeyRateWindowStart = sqlb.Col[*time.Time](TableKey, ColRateWindowStart)
	KeyRemaining       = sqlb.Col[*int64](TableKey, ColRemaining)
	KeyRefillAmount    = sqlb.Col[int64](TableKey, ColRefillAmount)
	KeyRefillInterval  = sqlb.Col[int64](TableKey, ColRefillInterval)
	KeyLastRefillAt    = sqlb.Col[*time.Time](TableKey, ColLastRefillAt)
)

Typed columns of migration 0002.

View Source
var (
	// ErrBadLimitsValue is a Limits that breaks the pairing rules or holds
	// a negative value: a configuration error from DefaultLimits (through
	// ErrBadLimits) or a caller error from CreateFor and SetLimits.
	ErrBadLimitsValue = errors.New("apikey: Limits pair RateMax with RateWindow and RefillAmount with RefillInterval, each at least a second, none negative")
	// ErrUsageExceeded is a request with a key whose Remaining reached zero
	// and that has no refill due.
	ErrUsageExceeded = auth.NewError("apikey.usage_exceeded", http.StatusTooManyRequests, "This key has used up its quota.")
	// ErrMetadataDisabled is a create or update carrying metadata while
	// AllowMetadata is not set.
	ErrMetadataDisabled = auth.NewError("apikey.metadata_disabled", http.StatusBadRequest, "This app does not keep metadata on keys.")
	// ErrBadMetadata is metadata that is not a JSON object or is longer than
	// maxMetadataLength bytes.
	ErrBadMetadata = auth.NewError("apikey.bad_metadata", http.StatusBadRequest, "Metadata must be a JSON object of at most 4096 bytes.")
	// ErrNoChanges is an update that sets nothing.
	ErrNoChanges = auth.NewError("apikey.no_changes", http.StatusBadRequest, "Nothing to update.")
)

Errors of limits, quotas, metadata and update.

View Source
var (
	// ErrOrganizationsRequired is a configuration error: an
	// OrganizationOwned configuration without WithOrganizations,
	// better-auth's ORGANIZATION_PLUGIN_REQUIRED, caught at auth.New rather
	// than on the first request.
	ErrOrganizationsRequired = errors.New("apikey: an OrganizationOwned configuration needs apikey.WithOrganizations")
	// ErrOrganizationIDRequired is a create under an OrganizationOwned
	// configuration that names no organization.
	ErrOrganizationIDRequired = auth.NewError("apikey.organization_id_required", http.StatusBadRequest, "Organization ID is required for organization-owned API keys.")
	// ErrNotOrganizationConfig is a create naming an organization under a
	// configuration whose keys belong to users.
	ErrNotOrganizationConfig = auth.NewError("apikey.not_organization_config", http.StatusBadRequest, "This key configuration does not make organization keys.")
	// ErrNotOrganizationMember is a create or list naming an organization
	// the caller is not a member of. Get, update and revoke of such an
	// organization's key answer ErrNotFound instead, so a key id probe
	// learns nothing.
	ErrNotOrganizationMember = auth.NewError("apikey.not_organization_member", http.StatusForbidden, "You are not a member of the organization that owns this API key.")
	// ErrOrganizationPermission is a member whose role does not grant the
	// apiKey action asked for.
	ErrOrganizationPermission = auth.NewError("apikey.organization_permission", http.StatusForbidden, "You do not have permission to perform this action on organization API keys.")
	// ErrOrganizationKey is an organization-owned key presented as a bearer
	// credential, under DistinctErrors (auth.ErrSignedOut otherwise):
	// better-auth's INVALID_REFERENCE_ID_FROM_API_KEY for such a key.
	ErrOrganizationKey = auth.NewError("apikey.organization_key", http.StatusUnauthorized, "An organization API key does not sign in as a user.")
	// ErrPermissionsOff is a key given permissions while WithPermissions is
	// off for its configuration.
	ErrPermissionsOff = auth.NewError("apikey.permissions_off", http.StatusBadRequest, "This app does not keep permissions on keys.")
	// ErrBadPermissions is permissions naming a resource or action the
	// WithPermissions vocabulary does not declare; at auth.New, the same
	// for DefaultPermissions.
	ErrBadPermissions = auth.NewError("apikey.bad_permissions", http.StatusBadRequest, "A permission in this request does not exist.")
	// ErrPermissionDenied is a key whose permissions do not grant what
	// VerifyPermissions or Authorize asked for, or a request made without a
	// key; Go only, like ErrScopeMissing.
	ErrPermissionDenied = auth.NewError("apikey.permission_denied", http.StatusForbidden, "This key does not allow this.")
)

Errors of organization-owned keys and key permissions.

View Source
var (
	KeyOrganization = sqlb.Col[*string](TableKey, ColOrganizationID)
	KeyPermissions  = sqlb.Col[*string](TableKey, ColPermissions)
)

Typed columns of migration 0004.

View Source
var (
	// ErrInvalidKey is a key that does not exist, failed PreCheck, or belongs
	// to another configuration than VerifyFor named. Verify reports it; over
	// HTTP an unknown key leaves the request signed out (401 auth.signed_out
	// on a route that needs a user), because a session token may start with
	// a key prefix by chance and is then looked up as a session, unless
	// RejectUnknownKeys makes it this error on every route.
	ErrInvalidKey = auth.NewError("apikey.invalid_key", http.StatusUnauthorized, "Invalid API key.")
	// ErrKeyDisabled is a key its owner paused (UpdateRequest.Disabled).
	ErrKeyDisabled = auth.NewError("apikey.key_disabled", http.StatusUnauthorized, "This API key is disabled.")
	// ErrKeyExpired is a key past its expiry.
	ErrKeyExpired = auth.NewError("apikey.key_expired", http.StatusUnauthorized, "This API key has expired.")
	// ErrKeyOwnerGone is a key whose user no longer exists, better-auth's
	// INVALID_REFERENCE_ID_FROM_API_KEY.
	ErrKeyOwnerGone = auth.NewError("apikey.invalid_owner", http.StatusUnauthorized, "The account behind this API key no longer exists.")
	// ErrOwnerBlocked is a key whose owner exists but may not sign in (an
	// admin ban, or an app's own SessionChecker), better-auth's USER_BANNED.
	ErrOwnerBlocked = auth.NewError("apikey.owner_blocked", http.StatusUnauthorized, "The account behind this API key cannot sign in.")
)

Errors that say why a key was refused, used only under DistinctErrors. By default every one of these cases is auth.ErrSignedOut, so whoever holds a key cannot learn whether it was revoked, paused or expired.

View Source
var ErrBadCacheTTL = errors.New("apikey: the cache TTL is between 0 (the default) and 15 minutes")

ErrBadCacheTTL is a configuration error: a WithCache TTL that is negative or above MaxCacheTTL.

View Source
var ErrPrefixNotDistinct = errors.New("apikey: RejectUnknownKeys needs every key prefix to hold a character outside base64url (A-Z a-z 0-9 - _), such as \".\"")

ErrPrefixNotDistinct is a configuration error: RejectUnknownKeys with a key prefix made only of base64url characters, which a core token could start with.

SortByValues is every sort field, for validation and documentation.

Functions

This section is empty.

Types

type Cache

type Cache interface {
	Get(ctx context.Context, key string) (value []byte, ok bool, err error)
	Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
	Delete(ctx context.Context, key string) error
}

Cache keeps looked-up keys for a short time so a busy key is not read from the database on every request: better-auth's secondary storage with fallbackToDatabase, where the database stays the source of truth. It is a plain byte store so any shared store (Redis, memcached) fits in a few lines; NewMemoryCache is the in-process one.

Values are keyed by the SHA-256 of the key, never the key, and hold the key's row without the secret. Get reports a miss as ok false; an error is a failure of the store itself.

type CreateRequest

type CreateRequest struct {
	// Config names the key configuration (Configuration); "" is
	// DefaultConfig. An unknown one is ErrUnknownConfig.
	Config string `json:"configId,omitempty"`
	Name   string `json:"name,omitempty"`
	// Scopes are the key's scopes; nil (the field left out) takes the
	// configuration's DefaultScopes, an empty list none. omitzero, not
	// omitempty, so a Go client that encodes an empty list still sends it.
	Scopes []string `json:"scopes,omitzero"`
	// Prefix is the key's own prefix after the configuration's, allowed
	// only under AllowKeyPrefix (ErrBadKeyPrefix otherwise).
	Prefix string `json:"prefix,omitempty"`
	// ExpiresInDays is optional; 0 means the key never expires, unless
	// WithExpiry sets a default or a maximum.
	ExpiresInDays int `json:"expiresInDays,omitempty"`
	// ExpiresIn is the expiry in seconds from now, better-auth's unit, as
	// an alternative to ExpiresInDays (setting both is ErrExpiryConflict).
	// The WithExpiry bounds apply to it measured in days, as better-auth
	// compares expiresIn with its minExpiresIn and maxExpiresIn days; 0
	// leaves the choice to ExpiresInDays.
	ExpiresIn int64 `json:"expiresIn,omitempty"`
	// Metadata is a JSON object kept with the key and returned in
	// Key.Metadata; it needs AllowMetadata (ErrMetadataDisabled otherwise).
	Metadata json.RawMessage `json:"metadata,omitempty"`
	// OrganizationID names the organization that owns the key; required by
	// an OrganizationOwned configuration (ErrOrganizationIDRequired) and
	// refused by any other (ErrNotOrganizationConfig). The caller needs the
	// apiKey "create" permission in it.
	OrganizationID string `json:"organizationId,omitempty"`
	// Permissions are the key's resource-to-actions permissions, checked
	// against WithPermissions (ErrBadPermissions); nil takes the
	// configuration's DefaultPermissions. Go only, never read from a client,
	// as better-auth makes permissions a server-only property: a user must
	// not grant their own key powers the app did not decide.
	Permissions Permissions `json:"-"`
}

CreateRequest creates a key. Every scope must be one declared with Scopes (ErrUnknownScope otherwise); repeats are stored once. Name is cut to NameLength runes (64 by default), or required by RequireName.

type CreateResult

type CreateResult struct {
	Key    Key    `json:"key"`
	Secret string `json:"secret"`
}

CreateResult shows the key once. Secret is a plain string on purpose: this response is the one place the key is meant to be seen.

type Expiry

type Expiry struct {
	MinDays     int
	MaxDays     int
	DefaultDays int
}

Expiry bounds CreateRequest.ExpiresInDays, in days. Zero fields are unset. With MaxDays set, a key that never expires is refused, so a policy such as "keys live at most a year" cannot be bypassed by leaving the field empty; DefaultDays fills an empty request instead.

type GetRequest

type GetRequest struct {
	ID string `json:"id"`
	// Config, when set, must be the key's configuration (ErrNotFound
	// otherwise), as better-auth's configId query does.
	Config string `json:"configId,omitempty"`
}

GetRequest names a key of the signed-in user. Another user's key id is ErrNotFound, the same as an unknown one. It arrives in a URL query.

type Key

type Key struct {
	ID string `json:"id"`
	// Config is the configuration the key was made under.
	Config string `json:"configId"`
	Name   string `json:"name,omitempty"`
	// Prefix is the key's prefix in clear: the configuration's and, under
	// AllowKeyPrefix, its own. Empty for a key made before migration 0003.
	Prefix     string     `json:"prefix"`
	Hint       string     `json:"hint"`
	Scopes     []string   `json:"scopes"`
	CreatedAt  time.Time  `json:"createdAt"`
	LastUsedAt *time.Time `json:"lastUsedAt"`
	ExpiresAt  *time.Time `json:"expiresAt"`
	// Disabled is true while the owner has paused the key (UpdateRequest
	// .Disabled); a disabled key is refused like an unknown one.
	Disabled bool `json:"disabled"`
	// Metadata is the owner's JSON object, or null.
	Metadata json.RawMessage `json:"metadata"`
	// OrganizationID is the owning organization of an organization-owned
	// key, "" for a user's key.
	OrganizationID string `json:"organizationId"`
	// Permissions are the key's permissions (WithPermissions), or null.
	Permissions Permissions `json:"permissions"`
	// Limits are the key's rate limit and quota; Remaining is live.
	Limits Limits `json:"limits"`
	// UpdatedAt is when the key last changed: made, edited, or used (a use
	// stamps it with last_used_at, at most once a minute, or with the
	// counters of a key that has limits). Nil for a key unchanged since
	// before migration 0003.
	UpdatedAt *time.Time `json:"updatedAt"`
}

Key is one key as its owner sees it, without the secret.

type Limits

type Limits struct {
	RateMax    int
	RateWindow time.Duration
	// Remaining uses; nil means unlimited. In a Key it is the live count.
	Remaining      *int
	RefillAmount   int
	RefillInterval time.Duration
}

Limits are a key's per-key rate limit and usage quota. The zero value is no limit, which every key created before round 2 has.

The rate limit is a fixed window: at most RateMax requests in any window of RateWindow, counted from the first request after a window ends. The quota is a count of uses that only goes down, unless RefillAmount and RefillInterval are set, in which case it is reset (not topped up) to RefillAmount once RefillInterval has passed since the last refill, or since creation. RateMax pairs with RateWindow and RefillAmount with RefillInterval: one without the other is ErrBadLimitsValue, because a cap without a window could never reopen. Windows and intervals are whole seconds; a fraction is truncated.

func (Limits) MarshalJSON

func (l Limits) MarshalJSON() ([]byte, error)

MarshalJSON writes the wire form.

func (Limits) None

func (l Limits) None() bool

None reports whether the limits are the zero value: no rate limit and no quota, so ResolveToken skips every counter write.

func (*Limits) UnmarshalJSON

func (l *Limits) UnmarshalJSON(b []byte) error

UnmarshalJSON reads the wire form. The parameter is the JSON boundary the standard library mandates.

type ListRequest

type ListRequest struct {
	// Config keeps the keys of one configuration; "" lists them all.
	Config string `json:"configId,omitempty"`
	// OrganizationID lists that organization's keys instead of the
	// caller's own; the caller needs the apiKey "read" permission in it.
	OrganizationID string `json:"organizationId,omitempty"`
	// Limit is the page size; "" or "0" is no limit.
	Limit string `json:"limit,omitempty"`
	// Offset is how many keys to skip; "" is none.
	Offset string `json:"offset,omitempty"`
	// SortBy is one of SortByValues; "" is SortByCreatedAt.
	SortBy string `json:"sortBy,omitempty"`
	// Sort is SortAsc or SortDesc; "" is SortAsc. A sort on a nullable field
	// (lastUsedAt, expiresAt) puts the NULL rows where the engine puts them.
	Sort string `json:"sort,omitempty"`
}

ListRequest pages and sorts the list. Every field is a string because it arrives in a URL query; the zero value is the whole list, oldest first (keys made in the same instant in id order, so every engine agrees).

type ListResult

type ListResult struct {
	Keys []Key `json:"keys"`
	// Total is how many keys the caller holds, whatever the page.
	Total int `json:"total"`
	// Limit and Offset echo the request as parsed, 0 for none.
	Limit  int `json:"limit"`
	Offset int `json:"offset"`
}

ListResult is the output of list: one page of the caller's keys without secrets, and the count of all of them.

type MemoryCache

type MemoryCache struct {
	// contains filtered or unexported fields
}

MemoryCache is an in-process Cache: a map with expiries, safe for concurrent use. Each instance of the app has its own, so a revoke on one instance reaches another only when that instance's entry expires; use a shared store when that matters. Expired entries are removed when read and by Prune.

func NewMemoryCache

func NewMemoryCache() *MemoryCache

NewMemoryCache returns an empty MemoryCache on the wall clock.

func NewMemoryCacheAt

func NewMemoryCacheAt(now func() time.Time) *MemoryCache

NewMemoryCacheAt is NewMemoryCache on the given clock, for tests that move time.

func (*MemoryCache) Delete

func (m *MemoryCache) Delete(_ context.Context, key string) error

Delete forgets key.

func (*MemoryCache) Get

func (m *MemoryCache) Get(_ context.Context, key string) ([]byte, bool, error)

Get returns key's value while it has not expired.

func (*MemoryCache) Prune

func (m *MemoryCache) Prune() int

Prune removes every expired entry and reports how many, for an app that wants to bound the map between reads.

func (*MemoryCache) Set

func (m *MemoryCache) Set(_ context.Context, key string, value []byte, ttl time.Duration) error

Set stores value under key for ttl.

type Option

type Option func(*Plugin)

Option configures the plugin; pass options to New.

func AllowKeyPrefix

func AllowKeyPrefix(max int) Option

AllowKeyPrefix lets a create name a prefix of its own for the key (CreateRequest.Prefix), as better-auth's prefix body field does: up to max characters of letters, digits, "_" and "-", placed after the configuration's prefix, which stays first because it routes the key to this plugin. The full prefix is stored in clear (Key.Prefix) and starts the hint. max 0 means maxKeyPrefixLength (32). Off by default: a prefix outside the configuration is refused with ErrBadKeyPrefix.

func AllowMetadata

func AllowMetadata() Option

AllowMetadata lets create and update store a JSON object (at most maxMetadataLength bytes) with each key, returned in Key.Metadata. Off by default, so an app that never reads metadata does not store whatever a client sends.

func Configuration

func Configuration(id string, opts ...Option) Option

Configuration adds a key configuration beside the default one, such as "live" and "test" with the prefixes "sk_live_" and "sk_test_": each has its own prefix, scopes, limits, expiry and name rules, and every key records the configuration it was made under (configId), all in the one auth_api_key table. A configuration starts from the plugin's own options and applies opts on top; Scopes adds to the inherited list. A create, get, update, revoke or list names one with configId; none means DefaultConfig. The plugin-wide options (WithCache, DistinctErrors, DeferUpdates) and a nested Configuration are refused inside one (ErrBadConfig).

better-auth's equivalent is apiKey([{ configId, ... }]). Unlike better-auth, an unknown configId is refused (ErrUnknownConfig) instead of falling back to the default configuration, so a typo cannot mint a key under the wrong prefix and limits.

func DefaultLimits

func DefaultLimits(l Limits) Option

DefaultLimits gives every key made through the create operation these limits; a key made from Go with CreateFor takes the limits passed to it. For an app whose keys should all be throttled the same way.

func DefaultPermissions

func DefaultPermissions(perms Permissions) Option

DefaultPermissions gives a key created without permissions these, better-auth's permissions.defaultPermissions. They must pass the WithPermissions vocabulary, or auth.New fails with ErrBadPermissions. nil clears an inherited default, inside a Configuration.

func DefaultPermissionsFunc

func DefaultPermissionsFunc(fn func(ctx context.Context, ownerID string) Permissions) Option

DefaultPermissionsFunc is DefaultPermissions decided per owner: fn gets the user id, or the organization id of an organization-owned key. A result outside the vocabulary fails the create with ErrBadPermissions. It wins over DefaultPermissions.

func DefaultScopes

func DefaultScopes(scopes ...string) Option

DefaultScopes gives a key created without a scopes field these scopes, as better-auth's permissions.defaultPermissions does; a request that sends scopes, even an empty list, gets exactly those. Each must be declared with Scopes, or auth.New fails with ErrBadScope.

func DefaultScopesFunc

func DefaultScopesFunc(fn func(ctx context.Context, userID string) []string) Option

DefaultScopesFunc is DefaultScopes decided per owner, better-auth's function form of defaultPermissions: fn gets the user id, or the organization id for an organization-owned key (OrganizationOwned). A scope it returns that was not declared fails the create with ErrUnknownScope: it is the app's bug, and a key must never carry a scope the app does not check. It wins over DefaultScopes.

func DeferUpdates

func DeferUpdates() Option

DeferUpdates moves the writes that do not decide the answer off the request, as better-auth's deferUpdates does: the last_used_at and updated_at stamp and DeleteSpentKeys' delete run through the core's background runner (auth.WithBackground) after the key was checked. The rate-limit and quota writes stay on the request, because they are the limit: deferred, they would let a burst through, which is also why better-auth's database path awaits them.

func DeleteSpentKeys

func DeleteSpentKeys() Option

DeleteSpentKeys deletes a key that can never work again when it is next presented, as better-auth always does: one past its expiry, and one whose quota reached zero with no refill. The request is still refused the same way. Off by default, so the owner's list shows a key that expired or ran out until auth.Sweep removes it.

func DistinctErrors

func DistinctErrors() Option

DistinctErrors answers each refused key with its own code, as better-auth does: apikey.key_disabled, apikey.key_expired, apikey.invalid_owner and apikey.owner_blocked (401) on every route, and apikey.invalid_key from Verify. The risk, which is why it is off by default: whoever holds a stolen key learns whether the owner paused it, let it expire or revoked it, and a request with a refused key fails with that 401 even on a route that would serve an anonymous caller, where the default lets it through signed out.

func Generate

func Generate(fn func() string) Option

Generate replaces the random part of a new key (the text after the prefix) with fn's result, for an app that needs a particular alphabet or length; the prefix stays, because it routes the key to this plugin. fn must return at least minSecretLength (22) characters from a cryptographic source; a shorter result fails the create with ErrShortSecret. The default is auth.NewToken: 32 random bytes, base64url.

func HintLength

func HintLength(n int) Option

HintLength sets how many characters after the prefix the hint keeps, at most maxHintLength (8); 0 keeps the prefix alone. The default is 4.

func MaxKeys

func MaxKeys(n int) Option

MaxKeys overrides MaxKeysPerUser, the cap on keys per account.

func NameBounds

func NameBounds(min, max int) Option

NameBounds refuses a name shorter than min or longer than max runes (ErrBadName) at create and rename, instead of the default of cutting a long name to NameLength. For apps whose key list is a form with its own rules. min 0 allows an empty name (unless RequireName); max must be at least min and positive, or Validate fails.

func NameLength

func NameLength(runes int) Option

NameLength overrides the default of 64 runes a key's name is cut to.

func OrganizationOwned

func OrganizationOwned() Option

OrganizationOwned makes a configuration's keys belong to an organization rather than a user, better-auth's references: "organization". A create names the organization (CreateRequest.OrganizationID) and needs the apiKey "create" permission there; get, list, update and revoke need "read", "read", "update" and "delete". Such a key never signs anyone in (no single user stands behind it): a bearer request with one is signed out (ErrOrganizationKey under DistinctErrors), and the app checks it with Verify, which reports Verification.OrganizationID. Off by default; it needs WithOrganizations (ErrOrganizationsRequired), and its prefix may not overlap a user-owned configuration's (ErrBadConfig), so a key's prefix alone says which kind it is.

func PreCheck

func PreCheck(fn func(ctx context.Context, key string) bool) Option

PreCheck runs fn on every presented key before the database lookup, as better-auth's customAPIKeyValidator does: a cheap shape or allow-list check that spares the lookup. false refuses the key exactly like an unknown one (auth.ErrSignedOut, or ErrInvalidKey under DistinctErrors). fn sees the whole key, so it must never log or store it. The configuration is chosen by the key's prefix, the longest that matches.

func Prefix

func Prefix(prefix string) Option

Prefix overrides DefaultPrefix. It must not collide with another plugin's.

func RejectUnknownKeys

func RejectUnknownKeys() Option

RejectUnknownKeys answers a bearer token that starts with a key prefix but is no key (unknown, revoked, or refused for a reason DistinctErrors would name) with 401 apikey.invalid_key, as better-auth's INVALID_API_KEY does, instead of the default, which lets the request through signed out (401 auth.signed_out only on a route that needs a user). Without DistinctErrors every refusal, a banned owner's included, is that same apikey.invalid_key, so the answer still says nothing about why; with it, only a key that was never found is.

The default exists because the core routes any bearer token that starts with a key prefix to this plugin, and when the plugin does not know the token it falls through to the sessions: a session token that happens to begin with the prefix must still work. Under this option nothing falls through, so auth.New refuses it unless every configuration's prefix holds a character no token the core issues can contain (core session tokens, stateless ones included, are base64url: letters, digits, "-" and "_"), such as "sk.live_" (ErrPrefixNotDistinct). That keeps the guarantee that no session is ever refused as a key.

The cost: a request carrying a bad key now fails with that 401 even on a route that serves anonymous callers. It weakens nothing, so auth.New does not warn about it.

func RequireName

func RequireName() Option

RequireName refuses a create without a name (ErrNameRequired), for apps whose key list must say what each key is for.

func Scopes

func Scopes(scopes ...string) Option

Scopes declares every scope a key may carry, such as "orders:read". A request for another scope is refused, so a typo cannot mint a key whose scope the app never checks. An app that decides only through permissions (WithPermissions) may leave it out: a key then carries no scope, and a request for one is refused as unknown.

func StampEveryUse

func StampEveryUse() Option

StampEveryUse makes every use of a key write last_used_at and updated_at, as better-auth's verify stamps updatedAt on every call, and makes that write a check: it is guarded on the key's id and runs on the request (never deferred by DeferUpdates), so a key deleted while its request was being checked is refused (auth.ErrSignedOut, or ErrInvalidKey under DistinctErrors) and its cache entry dropped, even when the key has no limits and so no counter write would notice. A failing write fails the request. The default writes last_used_at at most once a minute and only logs a failure, so a key without limits costs no write per request; this option buys exact timestamps and mid-request revocation for one write per use.

func WithCache

func WithCache(c Cache, ttl time.Duration) Option

WithCache puts c in front of the key lookup for ttl (0 is DefaultCacheTTL, at most MaxCacheTTL). A hit spares the lookup; the rate limit and quota still run as guarded writes against the database, re-reading the row when the cached counters are stale, so neither can be exceeded through the cache. Revoke, Update and SetLimits delete the entry. What stays true only up to the TTL: a key revoked on another instance with an in-process cache, or while a request was filling the entry, keeps working until the entry expires; and last_used_at moves only when the database is read, so at most once per TTL. A failing cache is logged and the database is read instead.

func WithExpiry

func WithExpiry(e Expiry) Option

WithExpiry bounds and defaults CreateRequest.ExpiresInDays.

func WithOrganizations

func WithOrganizations(o Organizations) Option

WithOrganizations supplies the organization membership an OrganizationOwned configuration checks. Plugin-wide: refused inside a Configuration (ErrBadConfig).

func WithPermissions

func WithPermissions(c PermissionChecker) Option

WithPermissions lets a key carry permissions, a resource-to-actions set checked by c, better-auth's permissions on a key; nil turns them off (inside a Configuration). c is a Vocabulary for the built-in check, or the adapter of a permission model the app picked (plugin/access's accessapikey). Permissions are set from Go only (CreateRequest.Permissions, CreateFor, SetPermissions, DefaultPermissions), as better-auth makes them a server-only property, and checked with VerifyPermissions or Authorize. Off by default: a key given permissions without it is ErrPermissionsOff. Scopes stay as they are; permissions are the richer check beside them.

type Organizations

type Organizations interface {
	APIKeyPermission(ctx context.Context, orgID, userID, action string) (member, allowed bool, err error)
}

Organizations is how the plugin asks about an organization without importing one: plugin/organization is its own module and depends on the core, so the core cannot depend on it. *organization.Plugin implements it; an app with its own organizations writes the method, or uses OrganizationsFunc.

APIKeyPermission reports whether userID is a member of orgID and, if so, whether their role grants action on ResourceAPIKey. The organization's creator role passes every action, as better-auth's allowCreatorAllPermissions does. err is only an infrastructure failure: a non-member is member false, not an error.

type OrganizationsFunc

type OrganizationsFunc func(ctx context.Context, orgID, userID, action string) (member, allowed bool, err error)

OrganizationsFunc adapts a function to Organizations.

func (OrganizationsFunc) APIKeyPermission

func (f OrganizationsFunc) APIKeyPermission(ctx context.Context, orgID, userID, action string) (bool, bool, error)

APIKeyPermission calls f.

type PermissionChecker

type PermissionChecker interface {
	// Check returns an error when p names anything the model does not
	// declare; the plugin wraps it in ErrBadPermissions.
	Check(p Permissions) error
	// Grants reports whether held covers want; want is never empty.
	Grants(held, want Permissions) bool
}

PermissionChecker is the permission model behind WithPermissions: which permissions a key may carry, and whether a key's permissions cover a check. Vocabulary is the built-in one; plugin/access's accessapikey adapts an access control, and an app may write its own. The plugin keeps the rules every model shares: a key without permissions, an empty check, and stored permissions Check no longer accepts all grant nothing. It must be safe for concurrent use.

type Permissions

type Permissions map[string][]string

Permissions are actions per resource, better-auth's permissions on a key: what a key holds, or what a check asks for. JSON-encoded it is better-auth's permission object. It has the shape of plugin/access.Permissions, so an app holding those converts with apikey.Permissions(p); the plugin keeps its own type because the core module carries no permission engine.

func (Permissions) Clone

func (p Permissions) Clone() Permissions

Clone returns a deep copy.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin is the API-key plugin: it owns auth_api_key, mints keys through its operations, and resolves "Bearer <prefix>…" tokens to their owner. Build it with New; the zero value has no operations.

func New

func New(opts ...Option) *Plugin

New builds the plugin and defines its operations. Scopes or WithPermissions is required: auth.New refuses a configuration that declares neither (ErrNoScopes).

func (*Plugin) Authorize

func (p *Plugin) Authorize(ctx context.Context, want Permissions) error

Authorize checks the API key of the current request against want, for an app's own routes: nil when the key's permissions grant every action, ErrPermissionDenied when they do not or when the request was not made with a key (a signed-in browser has no key permissions). It reads the key row once per call. Wrap it in authhttp.RequireFunc to guard a route.

func (*Plugin) Create

func (p *Plugin) Create(ctx context.Context, in CreateRequest) (CreateResult, error)

Create makes a key for the signed-in user. It needs a fresh sign-in, because a key outlives the session that made it.

func (*Plugin) CreateFor

func (p *Plugin) CreateFor(ctx context.Context, userID string, in CreateRequest, l Limits) (CreateResult, error)

CreateFor makes a key for userID from Go only, with the given limits: a provisioning job, an import, a key minted when a customer subscribes. The cap, the scope list, the name and expiry rules apply as in create; metadata is accepted without AllowMetadata, because the caller is the app, not a client. The session requirement does not apply: the caller is trusted code, like organization.CreateFor. Under an OrganizationOwned configuration the key belongs to in.OrganizationID and userID is the member creating it, whose role must grant the apiKey "create" permission, as better-auth checks a server-side create with userId.

func (*Plugin) DeleteOrganizationKeys

func (p *Plugin) DeleteOrganizationKeys(ctx context.Context, orgID string) (int64, error)

DeleteOrganizationKeys deletes every key orgID owns and their cache entries, for an app to call when it deletes the organization (from plugin/organization's Hooks.AfterDeleteOrg): no foreign key does it (ADR-0002), and a key of a deleted organization must stop working. It returns how many keys went.

func (*Plugin) Get

func (p *Plugin) Get(ctx context.Context, id string) (Key, error)

Get returns one key of the signed-in user; ErrNotFound for another user's or an unknown id.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core, whose connection and clock every statement uses.

func (*Plugin) List

func (p *Plugin) List(ctx context.Context) ([]Key, error)

List returns the signed-in user's keys, oldest first.

func (*Plugin) ListKeys

func (p *Plugin) ListKeys(ctx context.Context, in ListRequest) (ListResult, error)

ListKeys returns one page of the signed-in user's keys, sorted as asked, with the total.

func (*Plugin) Name

func (p *Plugin) Name() string

Name is PluginName, the owner recorded on the plugin's migrations; it never changes.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists create, list, get, revoke and update, for the transports to mount.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) OwnerRefused

func (p *Plugin) OwnerRefused() error

OwnerRefused is auth.OwnerRefusalReporter: under DistinctErrors a banned owner's key answers ErrOwnerBlocked, otherwise nil keeps ErrSignedOut.

func (*Plugin) ResolveToken

func (p *Plugin) ResolveToken(ctx context.Context, t auth.Token) (auth.Principal, error)

ResolveToken finds a key by its hash. An unknown, revoked, expired or disabled key is auth.ErrSignedOut, all alike (each its own code under DistinctErrors, ErrInvalidKey under RejectUnknownKeys alone). A key past its rate limit is auth.ErrRateLimited (with a Retry-After) and one past its quota ErrUsageExceeded: the key was valid, so saying why gives nothing away. last_used_at is written at most once a minute, and a failure to write it is only logged: it is information, not a check.

The lookup runs on the connection for ctx, like every other statement, so an Auth built without a database reports auth.ErrNoDatabase rather than dereferencing a nil handle.

func (*Plugin) Revoke

func (p *Plugin) Revoke(ctx context.Context, id string) error

Revoke deletes a key; the next request with it is signed out.

func (*Plugin) SetLimits

func (p *Plugin) SetLimits(ctx context.Context, id string, l Limits) error

SetLimits replaces a key's rate limit and quota from Go only, resetting the counters: the window starts again and Remaining becomes the new value. ErrNotFound for an unknown id. The key's cache entry is dropped.

func (*Plugin) SetPermissions

func (p *Plugin) SetPermissions(ctx context.Context, id string, perms Permissions) error

SetPermissions replaces a key's permissions from Go only; nil clears them. They are checked against the key's configuration's WithPermissions (ErrPermissionsOff, ErrBadPermissions). ErrNotFound for an unknown id. The key's cache entry is dropped, so the change applies to the next request.

func (*Plugin) Sweep

func (p *Plugin) Sweep(ctx context.Context, cutoff time.Time) (int64, error)

Sweep deletes keys that expired before cutoff. An expired key is already refused on use; deleting it keeps the table, and the owner's list, to keys that can still do something.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares auth_api_key and its alias, so auth.New can refuse a second owner of either.

func (*Plugin) TokenPrefix

func (p *Plugin) TokenPrefix() string

TokenPrefix routes bearer tokens that start with the prefix here instead of to the session table. auth.New refuses two resolvers whose prefixes overlap.

func (*Plugin) TokenPrefixes

func (p *Plugin) TokenPrefixes() []string

TokenPrefixes lists every configuration's prefix, so the core routes a key of any configuration here; overlapping prefixes are this plugin's own affair, because a key is found by its hash and its configuration read from its row.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates auth_api_key and its per-user index for engine. The key hash is UNIQUE: a lookup by hash must find at most one key.

func (*Plugin) Update

func (p *Plugin) Update(ctx context.Context, in UpdateRequest) (Key, error)

Update changes a key of the signed-in user. It needs a fresh sign-in, like create: resuming a key or extending its expiry hands out access that outlives the session.

func (*Plugin) UserTables

func (p *Plugin) UserTables() []auth.UserTable

UserTables declares that auth_api_key rows belong to the user in user_id, so DeleteUser removes them (no foreign key does, ADR-0002).

func (*Plugin) Validate

func (p *Plugin) Validate(*auth.Config) error

Validate checks every configuration (declared scopes the scopes column can store apart, a prefix a secret scanner can recognize, limits under which a create can succeed), the cache TTL, and under RejectUnknownKeys that no core token can start with a key prefix.

func (*Plugin) Verify

func (p *Plugin) Verify(ctx context.Context, secret string, scopes ...string) (Verification, error)

Verify checks a key the app received somewhere other than the Authorization header (a webhook signature, a websocket message, a CLI flag) and reports whether it is good and for whom, counting the use against the key's rate limit and quota exactly as a bearer request would. scopes, when given, must all be on the key. A key of any configuration is accepted; VerifyFor names one. Go only: it is the app's check, not a client's. The error is only an infrastructure failure; a bad key is a Verification with Valid false.

func (*Plugin) VerifyFor

func (p *Plugin) VerifyFor(ctx context.Context, config, secret string, scopes ...string) (Verification, error)

VerifyFor is Verify for keys of one configuration, better-auth's verify with configId: a key of another configuration is refused like an unknown one, before its counters move, and an unknown configuration is a Verification with Reason ErrUnknownConfig.

func (*Plugin) VerifyPermissions

func (p *Plugin) VerifyPermissions(ctx context.Context, secret string, want Permissions) (Verification, error)

VerifyPermissions is Verify that also requires the key's permissions to grant every action of want, better-auth's verify with permissions: a key without permissions, or whose permissions fall short, is a Verification with Reason ErrPermissionDenied. The use is counted either way, as Verify's scope check counts it.

type RevokeRequest

type RevokeRequest struct {
	ID string `json:"id"`
	// Config, when set, must be the key's configuration (ErrNotFound
	// otherwise).
	Config string `json:"configId,omitempty"`
}

RevokeRequest names a key of the signed-in user. Another user's key id is ErrNotFound, the same as an unknown one.

type UpdateRequest

type UpdateRequest struct {
	ID string `json:"id"`
	// Config, when set, must be the key's configuration (ErrNotFound
	// otherwise). The key's own configuration's rules apply either way.
	Config string `json:"configId,omitempty"`
	// Name renames the key; under RequireName a blank name is refused.
	Name *string `json:"name,omitempty"`
	// ExpiresInDays sets a new expiry from now, under the same WithExpiry
	// rules as create: 0 is "never" unless a default or a maximum applies.
	ExpiresInDays *int `json:"expiresInDays,omitempty"`
	// ExpiresIn sets a new expiry in seconds from now, better-auth's unit,
	// under the same rules as CreateRequest.ExpiresIn; setting it with
	// ExpiresInDays is ErrExpiryConflict.
	ExpiresIn *int64 `json:"expiresIn,omitempty"`
	// Disabled pauses the key (true) or resumes it (false). A disabled key
	// is refused exactly like an unknown one.
	Disabled *bool `json:"disabled,omitempty"`
	// Metadata replaces the key's metadata; needs AllowMetadata.
	Metadata json.RawMessage `json:"metadata,omitempty"`
	// Scopes replaces the key's scopes, each one declared with Scopes
	// (ErrUnknownScope otherwise); nil leaves them, an empty list clears
	// them (omitzero keeps that empty list on the wire). Widening a key's
	// scopes needs the same fresh sign-in as minting one with them.
	Scopes []string `json:"scopes,omitzero"`
}

UpdateRequest changes a key of the signed-in user. A nil field is left as it is; ErrNoChanges when every field is nil. Another user's key id is ErrNotFound, the same as an unknown one.

type Verification

type Verification struct {
	Valid  bool
	Reason error
	Key    *Key
	UserID string
	// OrganizationID is the owner of an organization-owned key, whose
	// UserID is "".
	OrganizationID string
}

Verification is Verify's answer. Valid is false for every key problem, with Reason saying which, as the bearer path would answer it: auth.ErrSignedOut (unknown, revoked, expired, disabled, or an owner who can no longer sign in; under DistinctErrors ErrInvalidKey, ErrKeyExpired, ErrKeyDisabled or ErrKeyOwnerGone), auth.ErrRateLimited, ErrUsageExceeded, ErrScopeMissing, and from VerifyFor ErrUnknownConfig. Key and UserID are set only when Valid.

type Vocabulary

type Vocabulary map[string][]string

Vocabulary is the built-in PermissionChecker: the resources a key's permissions may name and the actions each allows. auth.New refuses an empty one, a resource with no action and an empty or repeated action.

func Statements

func Statements() Vocabulary

Statements is the API-key vocabulary for an organization's access control: merge it into the app's statements so its roles can grant the actions. A new map each call, so an app can change its copy.

func (Vocabulary) Check

func (s Vocabulary) Check(p Permissions) error

Check reports whether p names only declared resources and actions.

func (Vocabulary) Grants

func (s Vocabulary) Grants(granted, want Permissions) bool

Grants reports whether granted covers every action of every resource of want: better-auth's role(permissions).authorize(want) with AND. An empty want, or a resource of want with no action, is denied, since asking for nothing proves nothing. A resource or action of want the vocabulary no longer declares is denied even when granted holds it (fails closed), and such stale entries of granted are otherwise ignored, so the key's other permissions keep their answer; accessapikey answers the same.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL