Skip to main content

better_auth_api/plugins/api_key/
mod.rs

1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use rand::seq::SliceRandom;
4use sha2::{Digest, Sha256};
5use std::sync::Mutex;
6
7use better_auth_core::entity::{AuthApiKey as _, AuthUser};
8use better_auth_core::store::ConsumeApiKeyResult;
9use better_auth_core::{AuthContext, AuthError, AuthResult, BeforeRequestAction};
10use better_auth_core::{AuthRequest, AuthResponse};
11
12pub(super) mod handlers;
13pub(super) mod types;
14
15#[cfg(test)]
16mod tests;
17
18use handlers::*;
19use types::*;
20
21// ---------------------------------------------------------------------------
22// Error codes -- mirrors the TypeScript `API_KEY_ERROR_CODES`
23// ---------------------------------------------------------------------------
24
25/// Dedicated API Key error codes aligned with the TypeScript `API_KEY_ERROR_CODES`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ApiKeyErrorCode {
28    InvalidApiKey,
29    KeyDisabled,
30    KeyExpired,
31    UsageExceeded,
32    KeyNotFound,
33    RateLimited,
34    UnauthorizedSession,
35    InvalidPrefixLength,
36    InvalidNameLength,
37    MetadataDisabled,
38    NoValuesToUpdate,
39    KeyDisabledExpiration,
40    ExpiresInTooSmall,
41    ExpiresInTooLarge,
42    InvalidRemaining,
43    RefillAmountAndIntervalRequired,
44    NameRequired,
45    InvalidUserIdFromApiKey,
46    NoDefaultConfiguration,
47    OrganizationIdRequired,
48    OrganizationPluginRequired,
49    UserNotMemberOfOrganization,
50    InsufficientApiKeyPermissions,
51    ServerOnlyProperty,
52    FailedToUpdateApiKey,
53    InvalidMetadataType,
54}
55
56impl ApiKeyErrorCode {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Self::InvalidApiKey => "INVALID_API_KEY",
60            Self::KeyDisabled => "KEY_DISABLED",
61            Self::KeyExpired => "KEY_EXPIRED",
62            Self::UsageExceeded => "USAGE_EXCEEDED",
63            Self::KeyNotFound => "KEY_NOT_FOUND",
64            Self::RateLimited => "RATE_LIMITED",
65            Self::UnauthorizedSession => "UNAUTHORIZED_SESSION",
66            Self::InvalidPrefixLength => "INVALID_PREFIX_LENGTH",
67            Self::InvalidNameLength => "INVALID_NAME_LENGTH",
68            Self::MetadataDisabled => "METADATA_DISABLED",
69            Self::NoValuesToUpdate => "NO_VALUES_TO_UPDATE",
70            Self::KeyDisabledExpiration => "KEY_DISABLED_EXPIRATION",
71            Self::ExpiresInTooSmall => "EXPIRES_IN_IS_TOO_SMALL",
72            Self::ExpiresInTooLarge => "EXPIRES_IN_IS_TOO_LARGE",
73            Self::InvalidRemaining => "INVALID_REMAINING",
74            Self::RefillAmountAndIntervalRequired => "REFILL_AMOUNT_AND_INTERVAL_REQUIRED",
75            Self::NameRequired => "NAME_REQUIRED",
76            Self::InvalidUserIdFromApiKey => "INVALID_USER_ID_FROM_API_KEY",
77            Self::NoDefaultConfiguration => "NO_DEFAULT_API_KEY_CONFIGURATION_FOUND",
78            Self::OrganizationIdRequired => "ORGANIZATION_ID_REQUIRED",
79            Self::OrganizationPluginRequired => "ORGANIZATION_PLUGIN_REQUIRED",
80            Self::UserNotMemberOfOrganization => "USER_NOT_MEMBER_OF_ORGANIZATION",
81            Self::InsufficientApiKeyPermissions => "INSUFFICIENT_API_KEY_PERMISSIONS",
82            Self::ServerOnlyProperty => "SERVER_ONLY_PROPERTY",
83            Self::FailedToUpdateApiKey => "FAILED_TO_UPDATE_API_KEY",
84            Self::InvalidMetadataType => "INVALID_METADATA_TYPE",
85        }
86    }
87
88    pub fn message(self) -> &'static str {
89        match self {
90            Self::InvalidApiKey => "Invalid API key.",
91            Self::KeyDisabled => "API Key is disabled",
92            Self::KeyExpired => "API Key has expired",
93            Self::UsageExceeded => "API Key has reached its usage limit",
94            Self::KeyNotFound => "API Key not found",
95            Self::RateLimited => "Rate limit exceeded.",
96            Self::UnauthorizedSession => "Unauthorized or invalid session",
97            Self::InvalidPrefixLength => "The prefix length is either too large or too small.",
98            Self::InvalidNameLength => "The name length is either too large or too small.",
99            Self::MetadataDisabled => "Metadata is disabled.",
100            Self::NoValuesToUpdate => "No values to update.",
101            Self::KeyDisabledExpiration => "Custom key expiration values are disabled.",
102            Self::ExpiresInTooSmall => {
103                "The expiresIn is smaller than the predefined minimum value."
104            }
105            Self::ExpiresInTooLarge => "The expiresIn is larger than the predefined maximum value.",
106            Self::InvalidRemaining => "The remaining count is either too large or too small.",
107            Self::RefillAmountAndIntervalRequired => {
108                "refillAmount and refillInterval must both be provided together"
109            }
110            Self::NameRequired => "API Key name is required.",
111            Self::InvalidUserIdFromApiKey => "The user id from the API key is invalid.",
112            Self::NoDefaultConfiguration => "No default api-key configuration found.",
113            Self::OrganizationIdRequired => {
114                "Organization ID is required for organization-owned API keys."
115            }
116            Self::OrganizationPluginRequired => {
117                "Organization plugin is required for organization-owned API keys. Please install and configure the organization plugin."
118            }
119            Self::UserNotMemberOfOrganization => {
120                "You are not a member of the organization that owns this API key."
121            }
122            Self::InsufficientApiKeyPermissions => {
123                "You do not have permission to perform this action on organization API keys."
124            }
125            Self::ServerOnlyProperty => {
126                "The property you're trying to set can only be set from the server auth instance only."
127            }
128            Self::FailedToUpdateApiKey => "Failed to update API key",
129            Self::InvalidMetadataType => "metadata must be an object or undefined",
130        }
131    }
132}
133
134pub(super) fn api_key_error(code: ApiKeyErrorCode) -> AuthError {
135    AuthError::bad_request(code.message())
136}
137
138/// Structured error returned by `validate_api_key`.
139pub(super) struct ApiKeyValidationError {
140    #[cfg_attr(
141        not(test),
142        expect(dead_code, reason = "read by the test module's verify_key helper")
143    )]
144    pub(super) code: ApiKeyErrorCode,
145    pub(super) message: String,
146}
147
148impl ApiKeyValidationError {
149    fn new(code: ApiKeyErrorCode) -> Self {
150        Self {
151            message: code.message().to_string(),
152            code,
153        }
154    }
155}
156
157// ---------------------------------------------------------------------------
158// Configuration
159// ---------------------------------------------------------------------------
160
161/// Which kind of entity a configuration's keys belong to.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
163pub enum ApiKeyReferences {
164    /// Keys are owned by the signed-in user.
165    #[default]
166    User,
167    /// Keys are owned by an organization; callers pass `organizationId` and
168    /// must hold the matching `apiKey` permission in that organization.
169    Organization,
170}
171
172/// API Key management plugin.
173pub struct ApiKeyPlugin {
174    /// The registered configurations. The first is used when a request names
175    /// no `configId` and none is marked `default`.
176    pub(super) configurations: Vec<ApiKeyConfig>,
177    /// Throttle for `delete_expired_api_keys` -- stores the last check instant.
178    last_expired_check: Mutex<Option<std::time::Instant>>,
179}
180
181impl ApiKeyPlugin {
182    /// Register an additional named configuration.
183    ///
184    /// Upstream allows several api-key configurations side by side, each with
185    /// its own `config_id`, ownership model and limits.
186    pub fn configuration(mut self, config: ApiKeyConfig) -> Self {
187        self.configurations.push(config);
188        self
189    }
190
191    /// Pick the configuration a request addressed, mirroring upstream's
192    /// `resolveConfiguration`: an unknown or absent `config_id` falls back to
193    /// the default one, and a missing default is a client error.
194    pub(super) fn resolve_configuration(
195        &self,
196        config_id: Option<&str>,
197    ) -> AuthResult<&ApiKeyConfig> {
198        if let Some(config_id) = config_id
199            && let Some(found) = self
200                .configurations
201                .iter()
202                .find(|config| config.config_id == config_id)
203        {
204            return Ok(found);
205        }
206
207        self.configurations
208            .iter()
209            .find(|config| is_default_config_id(&config.config_id))
210            .ok_or_else(|| api_key_error(ApiKeyErrorCode::NoDefaultConfiguration))
211    }
212
213    /// The default configuration, used by paths that are not config-scoped.
214    pub(super) fn default_configuration(&self) -> AuthResult<&ApiKeyConfig> {
215        self.resolve_configuration(None)
216    }
217}
218
219/// Keys written before `config_id` existed carry no value, so absent and
220/// `"default"` denote the same configuration.
221pub(super) fn is_default_config_id(config_id: &str) -> bool {
222    config_id.is_empty() || config_id == "default"
223}
224
225/// Whether a stored key belongs to the addressed configuration.
226pub(super) fn config_id_matches(key_config_id: &str, expected: &str) -> bool {
227    if is_default_config_id(key_config_id) && is_default_config_id(expected) {
228        return true;
229    }
230    key_config_id == expected
231}
232
233/// Configuration for the API Key plugin, aligned with the TypeScript `ApiKeyOptions`.
234#[derive(Debug, Clone)]
235pub struct ApiKeyConfig {
236    /// Name of this configuration, stored on every key it creates.
237    /// Upstream defaults it to `"default"`.
238    pub config_id: String,
239    /// Whether keys from this configuration belong to a user or to an
240    /// organization.
241    pub references: ApiKeyReferences,
242
243    // -- key generation --
244    pub key_length: usize,
245    pub prefix: Option<String>,
246    pub default_remaining: Option<i64>,
247
248    // -- header --
249    pub api_key_header: String,
250
251    // -- hashing --
252    pub disable_key_hashing: bool,
253
254    // -- starting characters --
255    pub starting_characters_length: usize,
256    pub store_starting_characters: bool,
257
258    // -- prefix length validation --
259    pub max_prefix_length: usize,
260    pub min_prefix_length: usize,
261
262    // -- name validation --
263    pub max_name_length: usize,
264    pub min_name_length: usize,
265    pub require_name: bool,
266
267    // -- metadata --
268    pub enable_metadata: bool,
269
270    // -- key expiration --
271    pub key_expiration: KeyExpirationConfig,
272
273    // -- rate limit defaults --
274    pub rate_limit: RateLimitDefaults,
275
276    // -- session emulation --
277    pub enable_session_for_api_keys: bool,
278}
279
280/// Key expiration constraints.
281#[derive(Debug, Clone)]
282pub struct KeyExpirationConfig {
283    /// Default `expiresIn` (in milliseconds) when none is provided. `None` = no default.
284    pub default_expires_in: Option<i64>,
285    /// If true, clients cannot set a custom `expiresIn`.
286    pub disable_custom_expires_time: bool,
287    /// Maximum `expiresIn` in **days**.
288    pub max_expires_in: i64,
289    /// Minimum `expiresIn` in **days**.
290    pub min_expires_in: i64,
291}
292
293impl Default for KeyExpirationConfig {
294    fn default() -> Self {
295        Self {
296            default_expires_in: None,
297            disable_custom_expires_time: false,
298            max_expires_in: 365,
299            min_expires_in: 1,
300        }
301    }
302}
303
304/// Global rate-limit defaults applied to newly-created keys.
305#[derive(Debug, Clone)]
306pub struct RateLimitDefaults {
307    pub enabled: bool,
308    /// Default time window in milliseconds.
309    pub time_window: i64,
310    /// Default max requests per window.
311    pub max_requests: i64,
312}
313
314impl Default for RateLimitDefaults {
315    fn default() -> Self {
316        Self {
317            enabled: true,
318            time_window: 86_400_000, // 24 hours
319            max_requests: 10,
320        }
321    }
322}
323
324impl Default for ApiKeyConfig {
325    fn default() -> Self {
326        Self {
327            config_id: "default".to_string(),
328            references: ApiKeyReferences::default(),
329            key_length: 64,
330            prefix: None,
331            default_remaining: None,
332            api_key_header: "x-api-key".to_string(),
333            disable_key_hashing: false,
334            starting_characters_length: 6,
335            store_starting_characters: true,
336            max_prefix_length: 32,
337            min_prefix_length: 1,
338            max_name_length: 32,
339            min_name_length: 1,
340            require_name: false,
341            enable_metadata: false,
342            key_expiration: KeyExpirationConfig::default(),
343            rate_limit: RateLimitDefaults::default(),
344            enable_session_for_api_keys: false,
345        }
346    }
347}
348
349// ---------------------------------------------------------------------------
350// Plugin implementation
351// ---------------------------------------------------------------------------
352
353/// Builder for [`ApiKeyPlugin`] powered by the `bon` crate.
354///
355/// Usage:
356/// ```ignore
357/// let plugin = ApiKeyPlugin::builder()
358///     .key_length(48)
359///     .prefix("ba_".to_string())
360///     .enable_metadata(true)
361///     .rate_limit(RateLimitDefaults { enabled: true, time_window: 60_000, max_requests: 5 })
362///     .build();
363/// ```
364#[bon::bon]
365impl ApiKeyPlugin {
366    #[builder]
367    pub fn new(
368        #[builder(default = "default".to_string())] config_id: String,
369        #[builder(default)] references: ApiKeyReferences,
370        #[builder(default = 64)] key_length: usize,
371        prefix: Option<String>,
372        default_remaining: Option<i64>,
373        #[builder(default = "x-api-key".to_string())] api_key_header: String,
374        #[builder(default = false)] disable_key_hashing: bool,
375        #[builder(default = 6)] starting_characters_length: usize,
376        #[builder(default = true)] store_starting_characters: bool,
377        #[builder(default = 32)] max_prefix_length: usize,
378        #[builder(default = 1)] min_prefix_length: usize,
379        #[builder(default = 32)] max_name_length: usize,
380        #[builder(default = 1)] min_name_length: usize,
381        #[builder(default = false)] require_name: bool,
382        #[builder(default = false)] enable_metadata: bool,
383        #[builder(default)] key_expiration: KeyExpirationConfig,
384        #[builder(default)] rate_limit: RateLimitDefaults,
385        #[builder(default = false)] enable_session_for_api_keys: bool,
386    ) -> Self {
387        Self {
388            configurations: vec![ApiKeyConfig {
389                config_id,
390                references,
391                key_length,
392                prefix,
393                default_remaining,
394                api_key_header,
395                disable_key_hashing,
396                starting_characters_length,
397                store_starting_characters,
398                max_prefix_length,
399                min_prefix_length,
400                max_name_length,
401                min_name_length,
402                require_name,
403                enable_metadata,
404                key_expiration,
405                rate_limit,
406                enable_session_for_api_keys,
407            }],
408            last_expired_check: Mutex::new(None),
409        }
410    }
411
412    pub fn with_config(config: ApiKeyConfig) -> Self {
413        Self {
414            configurations: vec![config],
415            last_expired_check: Mutex::new(None),
416        }
417    }
418
419    // -- internal helpers --
420
421    pub(super) fn generate_key(
422        config: &ApiKeyConfig,
423        custom_prefix: Option<&str>,
424    ) -> (String, String, String) {
425        // Match TS: generateRandomString(length, "a-z", "A-Z") — alpha only
426        const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
427        let mut rng = rand::thread_rng();
428        let raw: String = (0..config.key_length)
429            .map(|_| {
430                ALPHABET
431                    .choose(&mut rng)
432                    .copied()
433                    .map(char::from)
434                    .unwrap_or('a')
435            })
436            .collect();
437
438        let prefix = custom_prefix.or(config.prefix.as_deref()).unwrap_or("");
439        let full_key = format!("{}{}", prefix, raw);
440
441        // TS computes start from the full key (including prefix):
442        //   start = key.substring(0, charactersLength)
443        let start_len = config.starting_characters_length;
444        let start: String = full_key.chars().take(start_len).collect();
445
446        let hash = if config.disable_key_hashing {
447            full_key.clone()
448        } else {
449            Self::hash_key(&full_key)
450        };
451
452        (full_key, hash, start)
453    }
454
455    pub(super) fn hash_key(key: &str) -> String {
456        let mut hasher = Sha256::new();
457        hasher.update(key.as_bytes());
458        let digest = hasher.finalize();
459        URL_SAFE_NO_PAD.encode(digest)
460    }
461
462    /// Throttled cleanup -- at most once per 10 seconds.
463    pub(super) async fn maybe_delete_expired(
464        &self,
465        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
466    ) {
467        let should_run = {
468            let mut last = self
469                .last_expired_check
470                .lock()
471                .unwrap_or_else(|e| e.into_inner());
472            let now = std::time::Instant::now();
473            match *last {
474                Some(prev) if now.duration_since(prev).as_secs() < 10 => false,
475                _ => {
476                    *last = Some(now);
477                    true
478                }
479            }
480        };
481        if should_run {
482            let _ = ctx.database.delete_expired_api_keys().await;
483        }
484    }
485
486    // -- Validation helpers --
487
488    pub(super) fn validate_prefix(config: &ApiKeyConfig, prefix: Option<&str>) -> AuthResult<()> {
489        if let Some(p) = prefix {
490            let len = p.len();
491            if len < config.min_prefix_length || len > config.max_prefix_length {
492                return Err(api_key_error(ApiKeyErrorCode::InvalidPrefixLength));
493            }
494        }
495        Ok(())
496    }
497
498    /// Validate the `name` field.
499    ///
500    /// When `is_create` is true, `require_name` is enforced (name must be
501    /// present).  On updates `require_name` is **not** enforced -- the
502    /// caller may be updating unrelated fields without resending the name.
503    pub(super) fn validate_name(
504        config: &ApiKeyConfig,
505        name: Option<&str>,
506        is_create: bool,
507    ) -> AuthResult<()> {
508        if is_create && config.require_name && name.is_none() {
509            return Err(api_key_error(ApiKeyErrorCode::NameRequired));
510        }
511        if let Some(n) = name {
512            let len = n.len();
513            if len < config.min_name_length || len > config.max_name_length {
514                return Err(api_key_error(ApiKeyErrorCode::InvalidNameLength));
515            }
516        }
517        Ok(())
518    }
519
520    pub(super) fn validate_expires_in(
521        config: &ApiKeyConfig,
522        expires_in: Option<i64>,
523    ) -> AuthResult<Option<i64>> {
524        let cfg = &config.key_expiration;
525        if let Some(secs) = expires_in {
526            if cfg.disable_custom_expires_time {
527                return Err(api_key_error(ApiKeyErrorCode::KeyDisabledExpiration));
528            }
529            // expiresIn is in seconds; min/max are in days
530            let days = secs as f64 / 86_400.0;
531            if days < cfg.min_expires_in as f64 {
532                return Err(api_key_error(ApiKeyErrorCode::ExpiresInTooSmall));
533            }
534            if days > cfg.max_expires_in as f64 {
535                return Err(api_key_error(ApiKeyErrorCode::ExpiresInTooLarge));
536            }
537            Ok(Some(secs))
538        } else {
539            Ok(cfg.default_expires_in)
540        }
541    }
542
543    pub(super) fn validate_metadata(
544        config: &ApiKeyConfig,
545        metadata: &Option<serde_json::Value>,
546    ) -> AuthResult<()> {
547        if metadata.is_some() && !config.enable_metadata {
548            return Err(api_key_error(ApiKeyErrorCode::MetadataDisabled));
549        }
550        if let Some(v) = metadata
551            && !v.is_object()
552            && !v.is_null()
553        {
554            return Err(api_key_error(ApiKeyErrorCode::InvalidMetadataType));
555        }
556        Ok(())
557    }
558
559    pub(super) fn validate_refill(
560        refill_interval: Option<i64>,
561        refill_amount: Option<i64>,
562    ) -> AuthResult<()> {
563        match (refill_interval, refill_amount) {
564            (Some(_), None) | (None, Some(_)) => Err(api_key_error(
565                ApiKeyErrorCode::RefillAmountAndIntervalRequired,
566            )),
567            _ => Ok(()),
568        }
569    }
570
571    // -----------------------------------------------------------------------
572    // Route handlers
573    // -----------------------------------------------------------------------
574
575    async fn handle_create(
576        &self,
577        req: &AuthRequest,
578        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
579    ) -> AuthResult<AuthResponse> {
580        let (user, _session) = ctx.require_session(req).await?;
581        let body: CreateKeyRequest = match better_auth_core::validate_request_body(req) {
582            Ok(v) => v,
583            Err(resp) => return Ok(resp),
584        };
585        let response = create_key_core(&body, user.id(), self, ctx).await?;
586        Ok(AuthResponse::json(200, &response)?)
587    }
588
589    async fn handle_get(
590        &self,
591        req: &AuthRequest,
592        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
593    ) -> AuthResult<AuthResponse> {
594        let (user, _session) = ctx.require_session(req).await?;
595        let id = req
596            .query
597            .get("id")
598            .ok_or_else(|| AuthError::bad_request("Query parameter 'id' is required"))?;
599        let config_id = req.query.get("configId").map(String::as_str);
600        let response = get_key_core(id, config_id, user.id(), self, ctx).await?;
601        Ok(AuthResponse::json(200, &response)?)
602    }
603
604    async fn handle_list(
605        &self,
606        req: &AuthRequest,
607        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
608    ) -> AuthResult<AuthResponse> {
609        let (user, _session) = ctx.require_session(req).await?;
610        let query = ListKeysQuery::from_request(req);
611        let response = list_keys_core(user.id(), &query, self, ctx).await?;
612        Ok(AuthResponse::json(200, &response)?)
613    }
614
615    async fn handle_update(
616        &self,
617        req: &AuthRequest,
618        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
619    ) -> AuthResult<AuthResponse> {
620        let (user, _session) = ctx.require_session(req).await?;
621        let body: UpdateKeyRequest = match better_auth_core::validate_request_body(req) {
622            Ok(v) => v,
623            Err(resp) => return Ok(resp),
624        };
625        let response = update_key_core(&body, user.id(), self, ctx).await?;
626        Ok(AuthResponse::json(200, &response)?)
627    }
628
629    async fn handle_delete(
630        &self,
631        req: &AuthRequest,
632        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
633    ) -> AuthResult<AuthResponse> {
634        let (user, _session) = ctx.require_session(req).await?;
635        let body: DeleteKeyRequest = match better_auth_core::validate_request_body(req) {
636            Ok(v) => v,
637            Err(resp) => return Ok(resp),
638        };
639        let response = delete_key_core(&body, user.id(), self, ctx).await?;
640        Ok(AuthResponse::json(200, &response)?)
641    }
642
643    /// Core validation logic used by `before_request` and tests.
644    ///
645    /// Validation chain: exists -> disabled -> expired -> permissions ->
646    /// remaining/refill -> rate limit.
647    ///
648    /// Returns `Ok(ApiKeyView)` on success, or `Err(ApiKeyValidationError)` with
649    /// a structured error code.
650    pub(super) async fn validate_api_key(
651        &self,
652        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
653        raw_key: &str,
654        required_permissions: Option<&serde_json::Value>,
655    ) -> Result<ApiKeyView, ApiKeyValidationError> {
656        // The key has to be hashed before it can be looked up, so the lookup
657        // uses the addressed configuration's hashing setting. Everything after
658        // the lookup switches to the configuration that issued the key, which
659        // is what carries its rate limit and expiry policy.
660        let lookup_config = self
661            .default_configuration()
662            .map_err(|_| ApiKeyValidationError::new(ApiKeyErrorCode::NoDefaultConfiguration))?;
663        let hashed = if lookup_config.disable_key_hashing {
664            raw_key.to_string()
665        } else {
666            Self::hash_key(raw_key)
667        };
668
669        // Look up by hash
670        let api_key = ctx
671            .database
672            .get_api_key_by_hash(&hashed)
673            .await
674            .map_err(|_| ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey))?
675            .ok_or_else(|| ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey))?;
676
677        let config = self
678            .resolve_configuration(Some(&api_key.config_id))
679            .map_err(|_| ApiKeyValidationError::new(ApiKeyErrorCode::NoDefaultConfiguration))?;
680
681        // 1. Disabled?
682        if !api_key.enabled() {
683            return Err(ApiKeyValidationError::new(ApiKeyErrorCode::KeyDisabled));
684        }
685
686        // 2. Expired?
687        if let Some(expires_at_str) = api_key.expires_at()
688            && let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str)
689            && chrono::Utc::now() > expires_at
690        {
691            let _ = ctx.database.delete_api_key(&api_key.id()).await;
692            return Err(ApiKeyValidationError::new(ApiKeyErrorCode::KeyExpired));
693        }
694
695        // 3. Permissions check
696        if let Some(required) = required_permissions {
697            let key_perms_str = api_key.permissions().unwrap_or("");
698            if key_perms_str.is_empty() {
699                return Err(ApiKeyValidationError::new(ApiKeyErrorCode::KeyNotFound));
700            }
701            if !check_permissions(key_perms_str, required) {
702                return Err(ApiKeyValidationError::new(ApiKeyErrorCode::KeyNotFound));
703            }
704        }
705
706        // 4. Atomically consume one use: decrement remaining (with refill),
707        // increment rate-limit counter, update timestamps. All counter
708        // mutations happen inside a transaction against the locked row to
709        // prevent concurrent requests from corrupting counters.
710        let updated = match ctx
711            .database
712            .consume_api_key_usage(&api_key.id(), config.rate_limit.enabled)
713            .await
714            .map_err(|_| ApiKeyValidationError::new(ApiKeyErrorCode::FailedToUpdateApiKey))?
715        {
716            ConsumeApiKeyResult::Allowed(key) => *key,
717            ConsumeApiKeyResult::RateLimited => {
718                return Err(ApiKeyValidationError::new(ApiKeyErrorCode::RateLimited));
719            }
720            ConsumeApiKeyResult::UsageExhausted => {
721                return Err(ApiKeyValidationError::new(ApiKeyErrorCode::UsageExceeded));
722            }
723        };
724
725        // Throttled cleanup
726        self.maybe_delete_expired(ctx).await;
727
728        Ok(ApiKeyView::from(&updated))
729    }
730}
731
732// ---------------------------------------------------------------------------
733// AuthPlugin trait implementation
734// ---------------------------------------------------------------------------
735
736better_auth_core::impl_auth_plugin! {
737    ApiKeyPlugin, "api-key";
738    routes {
739        post "/api-key/create"                    => handle_create,             "api_key_create";
740        get  "/api-key/get"                       => handle_get,                "api_key_get";
741        post "/api-key/update"                    => handle_update,             "api_key_update";
742        post "/api-key/delete"                    => handle_delete,             "api_key_delete";
743        get  "/api-key/list"                      => handle_list,               "api_key_list";
744    }
745    extra {
746        async fn before_request(
747            &self,
748            req: &AuthRequest,
749            ctx: &AuthContext<S>,
750        ) -> AuthResult<Option<BeforeRequestAction>> {
751            let config = self.default_configuration()?;
752            if !config.enable_session_for_api_keys {
753                return Ok(None);
754            }
755
756            // Check for API key in the configured header
757            let raw_key = match req.headers.get(&config.api_key_header) {
758                Some(k) if !k.is_empty() => k.clone(),
759                _ => return Ok(None),
760            };
761
762            // Validate the key (reuses the full verify logic)
763            let view = self
764                .validate_api_key(ctx, &raw_key, None)
765                .await
766                .map_err(|e| AuthError::bad_request(e.message))?;
767
768            // Look up the user
769            let user = ctx
770                .database
771                .get_user_by_id(&view.reference_id)
772                .await?
773                .ok_or_else(|| api_key_error(ApiKeyErrorCode::InvalidUserIdFromApiKey))?;
774
775            // Build a virtual session response for `GET /get-session`.
776            //
777            // The POST form is deliberately left to the route, which gates it
778            // on `session.defer_session_refresh` and answers 405 otherwise —
779            // answering here would let an API key bypass that gate.
780            if req.path() == "/get-session" && req.method() == &better_auth_core::HttpMethod::Get {
781                let session_json = serde_json::json!({
782                    "user": {
783                        "id": user.id(),
784                        "email": user.email(),
785                        "name": user.name(),
786                    },
787                    "session": {
788                        "id": view.id,
789                        "token": raw_key,
790                        "userId": view.reference_id,
791                    }
792                });
793                return Ok(Some(BeforeRequestAction::Respond(AuthResponse::json(
794                    200,
795                    &session_json,
796                )?)));
797            }
798
799            // For all other routes, inject the session
800            Ok(Some(BeforeRequestAction::InjectSession {
801                user_id: view.reference_id,
802                session_token: raw_key,
803            }))
804        }
805    }
806}