lucid-auth is a native Rust authentication backend for applications using
the official Better Auth JavaScript client. It implements a deliberately
tested Better Auth-compatible HTTP and session surface without executing or
embedding a JavaScript authentication server.
The compatibility target is Better Auth 1.7.2. See the
compatibility matrix for method-level coverage, known
limitations, upgrade audit, and links to every tracked gap.
Read the complete guide at lucid-softworks.github.io/auth.
- Install and run memory, D1, SQLite, MySQL, MongoDB, MSSQL, or PostgreSQL storage
- Connect React, Vue, Svelte, Solid, vanilla, SSR, and extension clients
- Connect the pinned Electron main, preload, renderer, and browser-proxy clients
- Review the production proxy, TLS, cookie, CORS, secret, and migration checklist
- Choose a database ID strategy and migrate older UUID schemas
- Choose only supported core methods and plugins
- Enable Agent Auth and review its exact server/client boundary
The crate targets Rust 1.90 and Axum 0.8. The official client must be pinned to Better Auth 1.7.2. From a checkout, this starts the CI-compiled memory example:
export BETTER_AUTH_SECRET="$(openssl rand -base64 32)"
export BETTER_AUTH_URL="http://localhost:3000"
export FRONTEND_ORIGIN="http://localhost:5173"
cargo run --example http_memory --features axumFor durable local storage without a separate database server, enable sqlite
and run the native SQLx example. The store consumes the same resolved Better
Auth schema as the service and performs additive Better Auth 1.7.2 migrations:
export DATABASE_URL="sqlite://lucid-auth.db"
cargo run --example http_sqlite --features axum,sqliteSqliteStore accepts an existing SqlitePool, a URL, or caller-built SQLx
connection options. It does not set foreign keys, WAL, synchronous mode, busy
timeouts, shared cache, retries, or checkpoint policy. A plain
sqlite::memory: database must use one pool connection; use a file database or
an explicitly configured shared-memory URI for multiple connections. See the
SQLite storage matrix for the exact
native versus D1 boundary.
Cloudflare Workers use the separate d1 feature and D1Store; it binds every
runtime value through the Workers D1 prepared-statement API and does not enable
SQLx or the local sqlite feature. See the
D1 quickstart.
For a MySQL deployment, enable mysql, supply a pool whose sessions remain at
UTC, and run the native example. Startup rejects a non-UTC session instead of
serving with skewed Better Auth timestamps:
export DATABASE_URL="mysql://user:password@127.0.0.1:3306/lucid_auth"
cargo run --example http_mysql --features axum,mysqlMongoDB deployments can use the official Rust driver backend. A supplied
client enables multi-operation transactions by default, so standalone local
servers must explicitly set MONGODB_TRANSACTIONS=false:
export MONGODB_URI="mongodb://127.0.0.1:27017"
export MONGODB_DATABASE="lucid_auth"
export MONGODB_TRANSACTIONS=false
cargo run --example http_mongodb --features axum,mongodbMongoStore persists native BSON values, maps Better Auth id to MongoDB
_id, and lazily creates resolved table-level indexes before writes. See the
MongoDB quickstart for replica-set,
ID-strategy, and migration boundaries.
Microsoft SQL Server deployments use the native Tiberius/BB8 backend and an ADO-style connection string. Better Auth leaves MSSQL multi-operation adapter transactions disabled unless the wrapper explicitly enables them:
export DATABASE_URL='server=tcp:127.0.0.1,1433;database=lucid_auth;user=sa;password=YOUR_PASSWORD;TrustServerCertificate=true'
cargo run --example http_mssql --features axum,mssqlMssqlStore uses bound parameters and bracket-quotes schema-derived
identifiers, implements SQL Server OUTPUT/TOP/OFFSET ... FETCH behavior,
and applies an introspection-based additive migration plan. See the
MSSQL deployment guide before enabling transactions or
executing a plan.
The currently supported surface covers:
- Core authentication Sessions, email/password, verification, recovery, account lifecycle, social sign-in, and linked accounts. See Core client API.
- Authentication plugins OTP, phone number, username, passkeys, two-factor, anonymous users, multi-session, JWT, and one-time tokens. See Authentication plugins.
- Authorization and tenancy Admin operations, organizations, invitations, teams, custom roles, and organization-owned credentials. See Authorization and management plugins.
- Enterprise and machine identity SSO, SCIM, OAuth/OIDC provider and device flows, API keys, and MCP authorization. See Enterprise SSO, SCIM, and API and token plugins.
- Application platforms Browser clients, Expo and React Native, Electron, Google One Tap, and development OAuth proxying. See Expo and React Native and Electron.
- Security and developer tooling Pwned-password screening, rate limits, operator security, plugin hooks and migrations, OpenAPI, and test helpers. See Security, utility, and developer plugins.
- Payments and attribution Stripe, Polar, Autumn, Creem, Dodo Payments, Commet, Chargebee, and Dub. See Payments and analytics.
- Managed infrastructure Better Auth Infrastructure Dash, Sentinel, managed email, and managed SMS. See Better Auth Infrastructure.
The compatibility matrix remains the source of truth for method-level status, upstream versions, verification evidence, and intentional boundaries.
The library keeps authentication protocol details separate from host-product authorization. Core principals contain actor, subject, session, and credential provenance only. An explicitly enabled host-policy plugin may project a role; core-only principals leave it unset.
AuthConfig::database_id_generation is the exact native equivalent of Better
Auth 1.7.2's advanced.database.generateId. The default is not UUID: it creates
32-character a-zA-Z0-9 IDs in the application.
use lucid_auth::DatabaseIdGeneration;
// Better Auth's omitted/default advanced.database.generateId.
config.database_id_generation = DatabaseIdGeneration::Default;
// Better Auth generateId: false. Every inserted table needs a database default.
config.database_id_generation = DatabaseIdGeneration::Database;
// Better Auth generateId: "serial". PostgreSQL uses integer identity columns.
config.database_id_generation = DatabaseIdGeneration::Serial;
// Better Auth generateId: "uuid". PostgreSQL uses native UUID columns/defaults.
config.database_id_generation = DatabaseIdGeneration::Uuid;A callback receives Better Auth's logical model name and the presence-sensitive size requested by the calling path:
use lucid_auth::{
DatabaseIdGeneration, DatabaseIdGenerationRequest,
DatabaseIdGenerationResult, DatabaseIdGenerator,
};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
#[derive(Debug)]
struct ModelIds(AtomicU64);
impl DatabaseIdGenerator for ModelIds {
fn generate(
&self,
request: DatabaseIdGenerationRequest<'_>,
) -> DatabaseIdGenerationResult {
let sequence = self.0.fetch_add(1, Ordering::Relaxed);
DatabaseIdGenerationResult::Id(format!("app_{}_{sequence}", request.model))
}
}
config.database_id_generation =
DatabaseIdGeneration::Callback(Arc::new(ModelIds(AtomicU64::new(1))));All public record IDs and references remain strings, including serial database
values. DatabaseIdGeneration::Database is intentionally invalid with the
official memory-store behavior because no database supplies the omitted IDs;
use Default, Serial, Uuid, or a callback for memory-backed services.
Adapter capabilities such as native UUID support and disableIdGeneration are
adapter declarations, not additional application strategy names. There are no
legacy aliases for AuthIdGenerator, AuthConfig::id_generator, or implicit
UUID fallback.
The database ID compatibility table
documents callback precedence, forceAllowId, Test Utils fallback behavior,
and the complete memory/PostgreSQL boundary. Existing installations that used
the former UUID default must follow the
breaking database ID migration guide before
running migrations.
SsoPlugin targets the immutable @better-auth/sso@1.7.2 package. It exposes
the official signIn.sso and provider-management client methods, both OIDC
callback paths, SAML metadata/ACS/SLO routes, and the two domain-verification
methods only when domain verification is enabled. Durable deployments can use
DatabaseSsoStore; memory remains useful for tests and configured defaultSSO
providers take precedence without being persisted.
use lucid_auth::{DatabaseSsoStore, SsoOptions, SsoPlugin};
use std::sync::Arc;
config.add_plugin(SsoPlugin::with_store(
SsoOptions {
domain_verification: true,
..SsoOptions::default()
},
Arc::new(DatabaseSsoStore::new(auth_store.clone())),
))?;OIDC supports discovery or explicit endpoints, PKCE, client-secret basic/post, private-key JWT, verified ID tokens, UserInfo, mapped profiles, and the shared or provider-specific callback. SAML supports generated or supplied metadata, signing and encryption keys, signature/digest/encryption policy, bounded signed assertions, request correlation, replay prevention, IdP initiation when opted in, and optional single logout. Provider configuration is stored as the same plaintext JSON strings used upstream; list/get/update responses still redact client secrets, certificates, and private keys. Protect the database and its backups accordingly.
Provider schema remapping and additional fields follow the package's top-level
precedence and input/returned rules. resolveUser and mutation guards execute
inside native adapter transactions. Provisioning runs after authentication;
when the Organization plugin is installed, provider-bound and unambiguous
verified-domain memberships honor pending invitations and the configured role
resolver.
There is intentionally no separate oauth2Config, JavaScript sidecar, provider
SDK, secret-vault abstraction, background provisioning queue, or implicit SCIM
email link. Hosted directory/SSO administration belongs to the separately
configured Dash control plane rather than SsoPlugin itself.
See Enterprise SSO 1.7.2 for the exact
boundary.
SCIM is an inbound, server-only plugin. Durable deployments must use the database-backed store over SQLite or PostgreSQL because authoritative resource mutations, callbacks, connection fencing, and retirement require native interactive transactions. D1 and sequential adapter fallbacks are not valid SCIM stores.
use lucid_auth::{
DatabaseScimStore, ScimBearerCredential, ScimConnection, ScimOptions,
ScimPlugin,
};
use std::sync::Arc;
let scim = ScimPlugin::new(
ScimOptions {
connections: vec![ScimConnection::new(
"workforce-directory",
vec![ScimBearerCredential::new("primary", scim_bearer_secret)],
)],
..ScimOptions::default()
},
Arc::new(DatabaseScimStore::new(auth_store.clone())),
)?;
config.add_plugin(scim)?;Static bearer secrets remain in application configuration: they are not
hashed, scoped, expiring, or audited by SCIM. Enable managed_connections
with a dedicated secret of at least 32 characters when the application needs
one-time plaintext issuance, HMAC-SHA256 digests, scopes, expiry, rotation,
revocation, and lifecycle events. Keep that hashing secret out of logs and
rotate it only with a deliberate credential cutover.
ScimIdentity controls explicit stable-user linking and aggregate lifecycle;
ScimProjection maps direct SCIM Group membership to application roles. SCIM
never links by email, creates an Account/sign-in method, or depends on the
Organization plugin. SSO can call acquire_active_scim_user_link inside its
own resolver transaction to acquire an exact active external-ID link.
This implementation intentionally has no compatibility migration from older
scimProvider, Account-backed, Organization-backed, or legacy bearer shapes.
Existing directories must follow a coordinated cutover and fully reprovision
Users and Groups. See SCIM 1.7.2 for the exact
supported and unsupported boundary.
Register the exact native server counterpart and explicitly trust the application's production scheme:
use lucid_auth::ExpoPlugin;
config.set_base_url("https://auth.example.com")?;
config.trust_origin("myapp://")?;
config.add_plugin(ExpoPlugin::default())?;Install the pinned official client in the Expo application:
npm install --save-exact better-auth@1.7.2 @better-auth/expo@1.7.2
npx expo install expo-constants expo-linking expo-network expo-secure-store expo-web-browserUse expoClient() from @better-auth/expo/client with SecureStore. On native,
the client sends expo-origin, stored cookies, and x-skip-oauth-proxy; the
server preserves a real Origin, substitutes only the exact expo-origin
header when Origin is absent, and applies the normal trusted-origin/CSRF
policy. Relative callbackURL, newUserCallbackURL, and errorCallbackURL
values are deep-linked by the official client. Incorrect casing such as
callbackUrl is unsupported.
The plugin contributes only exp:// when NODE_ENV=development. It never
trusts myapp://, CIDR patterns, or production wildcards automatically. Its
hidden GET /expo-authorization-proxy accepts external HTTPS authorization
targets and uses core OAuth state cookies; callback, magic-link, and
email-verification redirects hand cookies only to trusted non-HTTP(S) schemes.
Do not log authorization URLs, redirect query strings, or cookie material.
Expo web is pass-through browser behavior and does not use SecureStore headers,
deep-link rewriting, or native session-cache hydration. SecureStore chunking,
cookie filtering, focus/network managers, session caching, and
lastLoginMethodClient remain client-side features of the pinned npm package;
the Rust plugin owns no schema, migration, device state, or retry layer. See the
framework guide for the complete
boundary.
Install the exact client package and enable its native server counterpart:
npm install --save-exact better-auth@1.7.2 @better-auth/electron@1.7.2use lucid_auth::ElectronPlugin;
config.add_plugin(ElectronPlugin::default())?;Use electronClient() only in Electron's main process, setupRenderer() in a
context-isolated preload, and electronProxyClient() in the web page that
hands the authenticated browser session back to the application. Keep the
session/cookie store in the main process. The complete setup, package entry
points, deep-link flow, and the pinned package's raw-versus-encoded token
boundary are documented in the framework guide.
Stripe support is opt-in and uses a narrow native HTTP client; no Node process, JavaScript sidecar, Stripe CLI, or general-purpose billing model is required. Keep both the Stripe API key and webhook secret in server-only environment variables:
use lucid_auth::{
MemoryStripeStore, StaticPlans, StripeHttpClient, StripeOptions, StripePlan,
StripePlugin, SubscriptionConfiguration, SubscriptionOptions,
};
use std::sync::Arc;
let stripe = Arc::new(StripeHttpClient::new(std::env::var("STRIPE_SECRET_KEY")?));
let mut stripe_options = StripeOptions::new(
stripe,
std::env::var("STRIPE_WEBHOOK_SECRET")?,
);
stripe_options.subscription = SubscriptionConfiguration::Enabled(
SubscriptionOptions::new(Arc::new(StaticPlans(vec![StripePlan {
name: "pro".into(),
price_id: Some("price_monthly".into()),
lookup_key: None,
annual_discount_price_id: None,
annual_discount_lookup_key: None,
limits: None,
group: None,
seat_price_id: None,
proration_behavior: Default::default(),
line_items: vec![],
free_trial: None,
}]))),
);
config.add_plugin(StripePlugin::new(
stripe_options,
Arc::new(MemoryStripeStore::new()),
))?;Use PostgresStripeStore in a PostgreSQL deployment, bind it through the
service, and apply the store's resolved schema. The browser client must use the same
pinned package and enable subscription inference explicitly:
import { createAuthClient } from "better-auth/client";
import { stripeClient } from "@better-auth/stripe/client";
export const authClient = createAuthClient({
plugins: [stripeClient({ subscription: true })],
});The webhook is POST /api/auth/stripe/webhook with the default auth base path.
Configure Stripe to send the untouched raw request body and stripe-signature
header there. Callback inputs use Better Auth's exact casing—most notably
callbackURL; aliases such as callbackUrl are deliberately unsupported. See
the Stripe compatibility row
for the audited boundary and issue link.
Polar support is opt-in and pins Better Auth 1.7.2,
@polar-sh/better-auth@1.8.4, and @polar-sh/sdk@0.47.1. Configure only the
feature factories used by the application; an empty feature list is valid, and
repeating one feature uses the last configuration, matching the adapter:
use lucid_auth::{
CheckoutOptions, PolarFeature, PolarHttpClient, PolarOptions, PolarPlugin,
PolarProduct, PolarProducts, PolarTheme, PortalOptions, UsageOptions,
WebhooksOptions,
};
use std::sync::Arc;
let checkout = CheckoutOptions {
products: Some(PolarProducts::static_products(vec![PolarProduct::new(
"polar_product_id",
"pro",
)])),
success_url: Some("/billing/success?checkout_id={CHECKOUT_ID}".into()),
authenticated_users_only: true,
..CheckoutOptions::default()
};
let portal = PortalOptions::new(
Some("https://app.example.com/account"),
Some(PolarTheme::Dark),
)?;
let webhooks = WebhooksOptions::new(std::env::var("POLAR_WEBHOOK_SECRET")?);
let mut polar_options = PolarOptions::new(
Arc::new(PolarHttpClient::new(std::env::var("POLAR_ACCESS_TOKEN")?)),
vec![
PolarFeature::Checkout(checkout),
PolarFeature::Portal(portal),
PolarFeature::Usage(UsageOptions::default()),
PolarFeature::Webhooks(webhooks),
],
);
polar_options.create_customer_on_sign_up = true;
config.add_plugin(PolarPlugin::new(polar_options))?;The portal-level return URL must be absolute because the upstream adapter
constructs it with new URL(returnUrl) when the feature is installed. Checkout
success and return URLs may be relative paths beginning with / or absolute
URLs. Configure Polar's webhook endpoint as
POST /api/auth/polar/webhooks, then enable its official browser client:
import { createAuthClient } from "better-auth/client";
import { polarClient } from "@polar-sh/better-auth/client";
export const authClient = createAuthClient({
plugins: [polarClient()],
});Polar is authoritative for customers, products, checkouts, subscriptions,
orders, benefits, meters, and events. PolarPlugin deliberately creates no
local billing or customer-mapping tables and contributes no migrations; it also
adds no retry or idempotency layer to Polar provider calls. See the
Polar compatibility row
for the exact endpoint, webhook, lifecycle, and recovery boundary.
Autumn support is opt-in and pins Better Auth 1.7.2, autumn-js@1.2.53,
and its generated SDK metadata version 0.10.18. By default, customers are
resolved from the authenticated user and the provider key is read from
AUTUMN_SECRET_KEY:
use lucid_auth::{AutumnCustomerScope, AutumnOptions, AutumnPlugin};
let mut autumn = AutumnOptions::default();
autumn.secret_key = Some(std::env::var("AUTUMN_SECRET_KEY")?);
autumn.base_url = Some("https://autumn-proxy.example.com/provider-prefix".into());
autumn.customer_scope = AutumnCustomerScope::UserAndOrganization;
config.add_plugin(AutumnPlugin::new(autumn))?;Omit secret_key to read AUTUMN_SECRET_KEY automatically. Use base_url for
an alternate Autumn API URL, or autumn_url for the adapter's higher-precedence
spelling. AutumnCustomerScope::User always uses the session user;
Organization requires an active organization;
UserAndOrganization prefers it and falls back to the user. For application-
specific identity rules, set identify to an AutumnIdentityProvider; its
trusted AutumnIdentity replaces the built-in scope resolver.
The official React provider selects Better Auth's path and credential defaults
only when useBetterAuth is enabled:
import { AutumnProvider } from "autumn-js/react";
export function Providers({ children }) {
return <AutumnProvider useBetterAuth={true}>{children}</AutumnProvider>;
}Direct client construction must supply those settings explicitly:
import { createAutumnClient } from "autumn-js/react";
export const autumn = createAutumnClient({
backendUrl: "https://auth.example.com",
pathPrefix: "/api/auth/autumn",
includeCredentials: true,
});The plugin exposes exactly Autumn's 15 camelCase POST endpoints. It owns no local billing models or migrations, accepts no client-selected customer, and adds no retries or idempotency behavior. Provider-produced errors intentionally retain Autumn's Better Auth 1.7.2 outer-HTTP-200 envelope; public request-schema errors remain HTTP 400. See the Autumn compatibility row for the complete transport, identity, fail-open, and exclusion boundary.
Creem support is opt-in and pins Better Auth 1.7.2,
@creem_io/better-auth@1.1.4, creem@1.6.0,
@creem_io/webhook-types@1.0.0, and the conformance oracle's zod@4.4.3.
Keep the API key and webhook secret in server-only environment variables. The
same core memory store must back both authentication and the Creem plugin:
use lucid_auth::{CreemOptions, CreemPlugin, MemoryStore};
use std::sync::Arc;
let store = Arc::new(MemoryStore::default());
let mut creem = CreemOptions::new(std::env::var("CREEM_API_KEY")?);
creem.webhook_secret = Some(std::env::var("CREEM_WEBHOOK_SECRET")?);
creem.default_success_url = Some("https://app.example.com/billing/success".into());
config.add_plugin(CreemPlugin::in_memory(creem, store.clone()))?;For PostgreSQL, construct the plugin store from the same PostgresStore and
the exact options used by the plugin. Add the plugin before evolving the schema
so its remapped user fields and creem_subscription model are included:
use lucid_auth::{AuthService, CreemOptions, CreemPlugin, PostgresCreemStore, PostgresStore};
use std::sync::Arc;
let store = PostgresStore::new(pool, Default::default());
let mut creem = CreemOptions::new(std::env::var("CREEM_API_KEY")?);
creem.webhook_secret = Some(std::env::var("CREEM_WEBHOOK_SECRET")?);
let creem_store = Arc::new(PostgresCreemStore::new(
store.clone(),
&creem.schema,
creem.persist_subscriptions,
)?);
config.add_plugin(CreemPlugin::new(creem, creem_store))?;
let service = AuthService::try_new(Arc::new(store.clone()), config)?;
store.migrate_all(&service.plugin_migrations()).await?;Set test_mode = true for Creem's test API. Setting
persist_subscriptions = false removes the plugin table, user fields, and
their resolved schema contributions; access checks then report that database
persistence is disabled.
The official browser client works unchanged:
import { createAuthClient } from "better-auth/client";
import { creemClient } from "@creem_io/better-auth/client";
export const authClient = createAuthClient({
plugins: [creemClient()],
});With the default auth base path, configure Creem to send webhooks to
POST /api/auth/creem/webhook. The route exists only when a non-empty webhook
secret is configured. Delivery is deliberately sequential, best-effort, and
non-transactional, matching the adapter: a customer link or trial flag can
remain written if a later subscription operation fails. There is no event
ledger, replay rejection, reordering, or retry queue, so every callback must be
idempotent and deployments need their own reconciliation process.
Applications that do not need HTTP routes can use CreemServerConfig with the
native direct helpers. Provider operations are create_creem_checkout,
create_creem_portal, cancel_creem_subscription,
retrieve_creem_subscription, and search_creem_transactions, with
create_creem_client exposing the narrow transport. The other helper
equivalents are is_active_creem_subscription, format_creem_date,
get_creem_days_until_renewal, validate_creem_server_webhook_signature,
check_creem_subscription_access, and get_active_creem_subscriptions. Only
the five provider operations require the API key.
See the Creem compatibility row for the exact endpoint, provider, persistence, webhook, callback, and helper boundary, including the intentional per-plugin schema-isolation improvement.
Dodo Payments support is opt-in and pins Better Auth 1.7.2,
@dodopayments/better-auth@1.6.5, @dodopayments/core@0.3.14, and
dodopayments@2.47.0. It requires a Dodo Payments account and a server-only
live or test API key; enable only the endpoint groups the application uses:
use lucid_auth::{
DodoCheckoutOptions, DodoPaymentsFeature, DodoPaymentsHttpClient,
DodoPaymentsOptions, DodoPaymentsPlugin, DodoPaymentsProviderConfig,
DodoProduct, DodoProducts, DodoWebhooksOptions,
};
use std::sync::Arc;
let client = Arc::new(DodoPaymentsHttpClient::new(
DodoPaymentsProviderConfig::live(std::env::var("DODO_PAYMENTS_API_KEY")?),
));
let checkout = DodoCheckoutOptions {
products: Some(DodoProducts::static_products(vec![DodoProduct::new(
"pdt_pro",
"pro",
)])),
success_url: Some("https://app.example.com/billing/success".into()),
authenticated_users_only: true,
};
let webhooks = DodoWebhooksOptions::new(
std::env::var("DODO_PAYMENTS_WEBHOOK_KEY")?,
);
let mut options = DodoPaymentsOptions::new(
client,
vec![
DodoPaymentsFeature::Checkout(checkout),
DodoPaymentsFeature::Portal,
DodoPaymentsFeature::Usage,
DodoPaymentsFeature::Webhooks(webhooks),
],
);
options.create_customer_on_sign_up = true;
config.add_plugin(DodoPaymentsPlugin::new(options, store.clone()))?;Use DodoPaymentsProviderConfig::test with a Dodo test-mode key. The native
create_customer_on_sign_up and get_customer_params options correspond to
upstream createCustomerOnSignUp and getCustomerParams; implement
DodoCustomerParamsProvider and assign it to options.get_customer_params to
add string metadata or an optional phone number during customer creation and
updates. The plugin stores only the optional, non-input dodoCustomerId user
field. Dodo remains authoritative for billing, and lucid-auth adds no payment,
subscription, usage, or webhook-delivery ledger.
Configure Dodo to deliver signed webhooks to
POST /api/auth/dodopayments/webhooks, and install the official browser client:
import { createAuthClient } from "better-auth/client";
import { dodopaymentsClient } from "@dodopayments/better-auth/client";
export const authClient = createAuthClient({
plugins: [dodopaymentsClient()],
});
await authClient.dodopayments.checkoutSession({
product_cart: [{ product_id: "pdt_pro", quantity: 1 }],
});
await authClient.dodopayments.customer.portal();
await authClient.dodopayments.customer.subscriptions.list();
await authClient.dodopayments.customer.payments.list();
await authClient.dodopayments.usage.ingest({
event_id: "request_123",
event_name: "api_request",
});
await authClient.dodopayments.usage.meters.list();dodopayments.checkoutSession is the preferred checkout API. The pinned
adapter also exposes the upstream-deprecated dodopayments.checkout method and
POST /dodopayments/checkout; they remain supported with their exact legacy
behavior because they are part of version 1.6.5, not as lucid-auth aliases. See
the Dodo Payments compatibility row
for the exact checkout, lifecycle, provider, webhook, and exclusion boundary.
Commet support is opt-in and pins Better Auth 1.7.2,
@commet/better-auth@8.1.0, and @commet/node@9.1.0. It requires a server-only
Commet API key beginning with ck_ and exposes only the endpoint groups
selected by the application. CommetProviderConfig::new validates that key and
returns a Result:
use lucid_auth::{
CommetFeature, CommetHttpClient, CommetOptions, CommetPlugin,
CommetPortalOptions, CommetProviderConfig, CommetSubscriptionsOptions,
CommetWebhooksOptions,
};
use std::sync::Arc;
let provider = CommetProviderConfig::new(std::env::var("COMMET_API_KEY")?)?;
let client = Arc::new(CommetHttpClient::new(provider));
let mut options = CommetOptions::new(
client,
vec![
CommetFeature::Portal(CommetPortalOptions {
return_url: Some("https://app.example.com/billing".into()),
}),
CommetFeature::Subscriptions(CommetSubscriptionsOptions::default()),
CommetFeature::Features,
CommetFeature::Usage,
CommetFeature::Seats,
CommetFeature::Webhooks(CommetWebhooksOptions::new(
std::env::var("COMMET_WEBHOOK_SECRET")?,
)),
],
);
options.create_customer_on_sign_up = true;
config.add_plugin(CommetPlugin::new(options))?;Set options.get_customer_create_params to a CommetCustomerParamsProvider
when signup should add a full name or arbitrary JSON metadata. The upstream
domain callback field is exposed but intentionally not forwarded by adapter
8.1.0. Commet owns all customer and billing state; the plugin adds no database
field, table, migration, organization mapping, or webhook-delivery ledger.
Install the official client and use its exact namespaces:
import { createAuthClient } from "better-auth/client";
import { commetClient } from "@commet/better-auth/client";
export const authClient = createAuthClient({ plugins: [commetClient()] });
await authClient.customer.portal();
await authClient.subscription.get();
await authClient.features.list();
await authClient.features.check("api-requests");
await authClient.usage.track({ feature: "api-requests", value: 1 });
await authClient.seats.list();Configure signed deliveries at POST /api/auth/commet/webhooks. See the
Commet compatibility row
for the exact 13 client actions, lifecycle, provider, retry, signature,
callback, and exclusion boundary.
Chargebee support is opt-in and pins Better Auth 1.7.2, the
Chargebee-maintained @chargebee/better-auth@1.2.0, and its
chargebee@3.23.1 runtime. The plugin is fully native: inject an application
implementation of ChargebeeClient that performs the required Chargebee API
operations and parses/authenticates webhooks. No Node process or JavaScript
sidecar is used.
use lucid_auth::{
ChargebeeClient, ChargebeeFreeTrial, ChargebeeOptions, ChargebeePlan,
ChargebeePlanType, ChargebeePlugin, ChargebeeSubscriptionOptions,
MemoryChargebeeStore, StaticChargebeePlans,
};
use std::sync::Arc;
// Application-owned native gateway; keep the site and API key server-side.
let provider: Arc<dyn ChargebeeClient> = Arc::new(MyChargebeeGateway::new(
std::env::var("CHARGEBEE_SITE")?,
std::env::var("CHARGEBEE_API_KEY")?,
));
let plans = Arc::new(StaticChargebeePlans(vec![ChargebeePlan {
name: "pro".into(),
item_price_id: "price_pro".into(),
item_id: None,
item_family_id: None,
plan_type: ChargebeePlanType::Plan,
billing_cycles: None,
free_trial: Some(ChargebeeFreeTrial { days: 7.0 }),
limits: Some(serde_json::json!({ "projects": 20 })),
}]));
let mut options = ChargebeeOptions::new(provider);
options.subscription = Some(ChargebeeSubscriptionOptions::new(true, plans));
options.create_customer_on_sign_up = true;
options.webhook_username = Some(std::env::var("CHARGEBEE_WEBHOOK_USERNAME")?.into());
options.webhook_password = Some(std::env::var("CHARGEBEE_WEBHOOK_PASSWORD")?.into());
let chargebee_store = Arc::new(MemoryChargebeeStore::new(store.clone()));
config.add_plugin(ChargebeePlugin::new(options, chargebee_store))?;MyChargebeeGateway above is application code implementing the narrow
ChargebeeClient trait; it is not a lucid-auth type. Use
PostgresChargebeeStore, bind it through AuthService, and evolve its resolved
schema for PostgreSQL.
For organization-owned subscriptions, also install the native Organization
plugin and enable ChargebeeOrganizationOptions; Chargebee organization mode
does not install Organization support implicitly.
Configure Chargebee to deliver webhooks to
POST /api/auth/chargebee/webhook. Basic authentication is enforced only when
both webhook credentials are configured. Then install the official browser
client with subscription inference enabled:
import { createAuthClient } from "better-auth/client";
import { chargebeeClient } from "@chargebee/better-auth/client";
export const authClient = createAuthClient({
plugins: [chargebeeClient({ subscription: true })],
});
await authClient.subscription.create({
itemPriceId: "price_pro",
successUrl: "/billing/success",
cancelUrl: "/billing",
});The provider remains authoritative while lucid-auth stores the adapter's local
customer linkage, subscription, and subscription-item projection. Create and
update accept upstream's declared returnUrl field but do not use it; callback
queries use exact callbackURL casing. Webhook handling intentionally awaits
authentication, native processing, custom listeners, and event-bus persistence
before acknowledging a delivery, fixing the published adapter's unsafe early
acknowledgement race. See the
Chargebee compatibility details for the exact
route, lifecycle, schema, webhook, and conformance boundary.
Dub support is opt-in and pins Better Auth 1.7.2,
@dub/better-auth@0.0.6, and dub@0.66.5. Inject only the application-owned
native lead transport that the adapter needs; lucid-auth does not start Node,
embed JavaScript, or expose Dub credentials to requests:
use lucid_auth::{DubLead, DubLeadError, DubOptions, DubPlugin, FnDubLeadTracker};
use std::sync::Arc;
let tracker = Arc::new(FnDubLeadTracker::new(|lead: DubLead| async move {
// Application code: send `lead` with a server-side Dub SDK or HTTP client.
send_lead_to_dub(lead)
.await
.map_err(|error| DubLeadError::new(error.to_string()))
}));
let mut dub = DubOptions::new(tracker);
dub.lead_event_name = Some("Signed Up".into());
config.add_plugin(DubPlugin::new(dub))?;Place dub_id yourself after obtaining the user's consent. The plugin reads
that exact case-sensitive cookie after any user creation, percent-decodes its
first value, and sends clickId, eventName, and the new user's id, name,
email, and optional image. It does not create or validate the attribution
cookie. On a default provider result—success or rejection—it emits the
upstream adapter's exact pathless deletion header. Because the header has no
Path, it may not remove a source cookie that was scoped to /; applications
remain responsible for cookie placement, consent, and cleanup.
Set disable_lead_tracking = true to leave both tracking and the cookie
untouched. An empty lead_event_name falls back to Sign Up. Set
custom_lead_track to an Arc<dyn DubCustomLeadTrack> or use
FnDubCustomLeadTrack when the application must replace the Dub call entirely.
The callback receives the persisted user and request context. Its failure is
deliberately visible as an empty HTTP 500 after the user, credential account,
and session have committed, and all response cookies are discarded, matching
the pinned adapter.
Do not install a Dub browser client for this target. Although upstream docs
show @dub/better-auth/client, version 0.0.6 does not export that subpath.
Its only server route, POST /api/auth/dub/link, also cannot complete OAuth
under Better Auth 1.7.2: without OAuth configuration it returns 404, and with
configuration it reaches an upstream missing-endpoint error and returns an
empty 500. Lucid-auth reproduces those observable outcomes and does not invent
a callback route or repaired client contract. See the
Dub compatibility details.
DashPlugin installs all 79 /dash/* routes published by
@better-auth/infra@0.4.3, including configuration/validation, user CRUD and
NDJSON export, account/password/session management, impersonation, moderation,
analytics, email actions, the five-action raw adapter endpoint, organization,
member, invitation and team management, managed two-factor setup, and the exact
SSO, domain-verification, legacy-directory, and managed-SCIM control plane. Managed
JWT authorization is mandatory for the administrative routes;
/dash/validate alone skips the JTI lookup. The four browser invitation
completion routes instead use managed invitation tokens or the local social
session, matching the pinned plugin.
Installing DashPlugin also projects the exact 35 audit events emitted by the
pinned artifact from user, session, account, verification, request-after, and
organization lifecycle hooks. The four exported email-change/two-factor
constants remain intentionally un-emitted. Each observation schedules one
best-effort POST /events/track; an HTTP error or thrown transport failure does
not fail authentication, and there is no retry, queue, replay, or delivery
guarantee.
It also installs the four local-session GET /events/* routes. events/list
and events/audit-logs expose only the signed-in user or an organization in
which that user is a member. events/all-audit-logs requires a literal
owner or admin membership and scopes unqualified queries to those elevated
organizations. These routes do not accept hosted Dash JWT claims as local
identity. The official dashClient() from @better-auth/infra/client exposes
only dash.getAuditLogs() and dash.getAllAuditLogs(); the list and event-type
routes remain server-only and are not extra browser actions.
use lucid_auth::{
AuthConfig, DashActivityTracking, DashManagedDirectorySync, DashOptions, DashPlugin,
InfraConnectionOptions,
};
use std::time::Duration;
let mut auth = AuthConfig::new([42_u8; 32])?;
auth.add_plugin(DashPlugin::new(DashOptions {
connection: InfraConnectionOptions {
api_key: Some(std::env::var("BETTER_AUTH_API_KEY")?),
..InfraConnectionOptions::default()
},
activity_tracking: DashActivityTracking {
enabled: true,
update_interval: Duration::from_secs(300),
},
managed_directory_sync: DashManagedDirectorySync {
enabled: true,
..DashManagedDirectorySync::default()
},
}))?;Activity tracking is opt-in and adds the optional lastActiveAt user field.
The verification and reset-email routes reuse the application's configured
Better Auth email callbacks; they do not silently install a second provider.
The shared DashJwtVerifier and IdentificationService remain available for
applications that need the lower-level hosted-JWT and identification substrate.
The API client sends the configured credential to BETTER_AUTH_API_URL or
https://dash.better-auth.com; the KV lookup client uses
BETTER_AUTH_KV_URL or https://kv.better-auth.com. Hosted authorization also
sends JWT/JTI data and fetches JWKS. Identification lookups send request IDs and
can return visitor, IP, location, browser, confidence, incognito, and bot data.
Keep credentials server-side and configure only origins trusted with that data.
Event records remain provider-owned: query filters transmit user and
organization identifiers to the API origin, and responses can contain audit
PII including account identifiers, email addresses, IP/location fields, and
event-specific data. Lucid-auth neither persists those records nor adds a
replay, retention, or delivery API.
Organization, member, team, invitation, and two-factor mutations reuse the
installed native stores and lifecycle hooks. Invitation verification,
expiration, acceptance, and one-time handoff redemption send the configured
API key and invitation payload to the Dash API origin. TOTP secrets and backup
codes remain encrypted in the native two-factor store and are returned in
plaintext only by their pinned initial-generation responses.
Managed directory setup requires the native organization and managed SCIM
plugins. Optional SSO pairing binds one persisted provider to one active
directory, rejects authentication-boundary provider changes while paired, and
links sign-ins only through active SCIM external-ID provenance. SCIM user state
projects membership with created-versus-observed ownership, so deprovisioning
never removes a membership that predated directory sync. Create/recovery and
rotation responses are the only places that return plaintext SCIM tokens;
store them immediately and do not log them. Organization, provider, domain,
directory, actor, user, and membership identifiers cross the configured
Infrastructure/identity-provider boundaries.
See the exact Dash core and substrate compatibility boundary.
SentinelPlugin matches the server sentinel() export from the immutable
@better-auth/infra@0.4.3 artifact when composed with Better Auth 1.7.2. It
adds request and database lifecycle hooks, the __infra-rid request-id cookie,
and remote security evaluation; it adds no endpoint, schema, migration, rate
rule, durable local decision store, or local audit log.
Install and configure the server package separately:
npm install --save-exact better-auth@1.7.2 @better-auth/infra@0.4.3use lucid_auth::{
AuthConfig,
infra::{
dash::InfraConnectionOptions,
sentinel::{SecurityOptions, SentinelOptions, SentinelPlugin},
},
};
let mut auth = AuthConfig::new([42_u8; 32])?;
auth.add_plugin(SentinelPlugin::new(SentinelOptions {
connection: InfraConnectionOptions {
api_key: Some(std::env::var("BETTER_AUTH_API_KEY")?),
..InfraConnectionOptions::default()
},
security: SecurityOptions::default(),
}))?;Browser applications install the official client from its authoritative
@better-auth/infra/client entry point:
import { createAuthClient } from "better-auth/client";
import { sentinelClient } from "@better-auth/infra/client";
export const authClient = createAuthClient({
plugins: [sentinelClient()],
});React Native and Expo applications use the distinct native entry point. The
published package marks AsyncStorage, Expo Constants, Expo Crypto, and Expo
Device as optional peers; install the ones used by the application. Provide
explicit async storage for a stable per-install visitor ID when possible, and
ensure either globalThis.crypto.getRandomValues or Expo Crypto is available.
Sentinel deliberately refuses Math.random as a secure-RNG fallback.
npx expo install @react-native-async-storage/async-storage expo-constants expo-crypto expo-deviceimport { createAuthClient } from "better-auth/client";
import { sentinelNativeClient } from "@better-auth/infra/native";
import AsyncStorage from "@react-native-async-storage/async-storage";
export const authClient = createAuthClient({
plugins: [sentinelNativeClient({ storage: AsyncStorage })],
});Sentinel is a remote security boundary. Browser/native fingerprint components, stable visitor and request IDs, IP/location, identifiers, account IDs, email addresses, user agents, security outcomes, and keyed failed-password fingerprints can leave the application. The KV origin receives identify payloads and lookups plus email-validity requests. The Infra API origin receives security checks, cooldown lookups, failed-attempt state, PoW generation, impossible-travel/location state, free-trial reservations, breached-password prefixes, stale-user checks, policy resolution, and security telemetry. Stale account notifications additionally use the managed email service. Review consent, privacy notice, retention, residency, processor terms, and applicable regional requirements before enabling the plugin.
A missing API key emits the published startup warning; there is no configurable global outage policy. General checks, cooldown lookup, challenge generation, failed-attempt tracking, impossible-travel, breached-password, stale-user, and email-validation failures retain their individual 0.4.3 fallbacks, many of which allow the request. Free-trial protection is the important exception: it blocks account creation when enabled but no bound identification exists. See the exact Sentinel compatibility and egress boundary.
Managed email support pins @better-auth/infra@0.4.3 and is a standalone
outbound client, not a Better Auth plugin. Create an EmailSender once and call
its send, send_bulk, or get_templates method from the application-owned
callback that already handles verification, password reset, Email OTP,
organization invitations, or another email-producing lifecycle. The one-shot
send_email and send_bulk_emails functions are available when reusing a
sender is unnecessary. Nothing is registered with AuthConfig, and installing
this crate does not automatically redirect existing delivery callbacks to the
managed service.
use lucid_auth::{
EmailConfig, EmailSender, ResetPasswordVariables, SendEmailOptions,
};
let sender = EmailSender::new(Some(EmailConfig {
api_key: Some(std::env::var("BETTER_AUTH_API_KEY")?),
..EmailConfig::default()
}));
let result = sender
.send(SendEmailOptions::new(
"person@example.com",
ResetPasswordVariables::new(
"https://app.example.com/reset?token=...",
"person@example.com",
),
))
.await;
if !result.success {
// Apply the application's delivery-failure policy here.
}For core password resets, wrap the call above in the application's
PasswordResetEmailSender implementation and assign it to
config.email_and_password.send_reset_password; verification and the optional
plugins have their own typed sender callbacks. That adapter performs the field
mapping explicitly, so installing managed email never changes an existing
delivery path.
The exact template IDs are verify-email, reset-password, change-email,
sign-in-otp, verify-email-otp, reset-password-otp, magic-link,
two-factor, invitation, application-invite, delete-account,
stale-account-user, and stale-account-admin. Each has a typed native
variable structure matching the published required and optional string fields.
The client exposes no arbitrary body, attachment, locale, request ID, provider,
callback URL, or idempotency option.
By default, configuration reads BETTER_AUTH_API_KEY, uses
BETTER_AUTH_API_URL or https://dash.better-auth.com/api, and applies the
package's three-second timeout.
An explicit API URL receives both the bearer credential and the complete
message payload. Recipient addresses, subjects, links, OTPs, invitation data,
IP addresses, and other template variables therefore cross that remote trust
boundary; keep the key server-side and configure only an origin you trust.
Each call performs exactly one managed-service request. Bulk send remains one remote bulk operation, and the managed backend—not this client—combines shared variables with per-recipient overrides. There is no automatic retry, backoff, queue, delivery ledger, local provider fallback, or reconciliation. See the managed email compatibility details for the precise request, configuration, result, and failure contract.
Managed SMS support also pins @better-auth/infra@0.4.3. It is a standalone
outbound client, not an auth plugin: creating an SmsSender does not install a
route, schema, migration, browser client, or automatic dash/sentinel
delivery hook. Call it explicitly from an application-owned phone-number or
two-factor OTP sender. The one-shot send_sms function is available when a
reusable sender is unnecessary.
use lucid_auth::{SendSmsOptions, SmsConfig, SmsSender, SmsTemplateId};
let sender = SmsSender::new(Some(SmsConfig {
api_key: Some(std::env::var("BETTER_AUTH_API_KEY")?),
..SmsConfig::default()
}));
let result = sender
.send(
SendSmsOptions::new("+1234567890", "123456")
.with_template(SmsTemplateId::PhoneVerification)
.with_client_ip("203.0.113.8"),
)
.await;
if !result.success {
// Apply the application's delivery-failure policy here.
}For PhoneNumberPlugin, adapt the sender through PhoneNumberOtpSender and
assign it to PhoneNumberConfig::send_otp; use TwoFactorOtpSender with
OtpConfig for delivered two-factor codes. That explicit adapter selects
phone-verification, two-factor, sign-in-otp, or no template. The managed
callable surface has no template-variables input even though the upstream
TypeScript declarations publish a variable type for template metadata.
Configuration and transport use the same API-key, API-origin, /api suffix,
three-second timeout, bearer header, and infrastructure user agent as the
published package. A truthy client_ip adds x-better-auth-client-ip. Every
call sends the phone number, OTP, selected template, and optional end-user IP
to the configured origin exactly once. There is no local E.164 validation,
retry, batching, idempotency key, queue, status polling, webhook, locale,
provider selection, or password-reset template. See the
managed SMS compatibility details.
Social providers use the same signIn.social and /callback/:provider wire
contract as Better Auth. Register a built-in after setting the public base URL:
config.set_base_url("https://auth.example.com")?;
config.add_social_provider(BuiltinProvider::new(
BuiltinProviderKind::Google,
std::env::var("GOOGLE_CLIENT_ID")?,
std::env::var("GOOGLE_CLIENT_SECRET")?,
))?;The built-in vocabulary is Apple, Atlassian, Cognito, Discord, Dropbox,
Facebook, Figma, GitHub, GitLab, Google, Hugging Face, Kakao, Kick, LINE,
Linear, LinkedIn, Microsoft, Naver, Notion, Paybin, PayPal, Polar, Railway,
Reddit, Roblox, Salesforce, Slack, Spotify, TikTok, Twitch, X/Twitter, Vercel,
VK, WeChat, and Zoom. Cognito, self-hosted GitLab, and tenant-specific Microsoft
setups have focused constructors; config_mut exposes documented endpoint,
scope, token-authentication, and profile policies. Implement SocialProvider
to add a provider without changing OAuth state, callback, account, token, or
session orchestration.
Better Auth's genericOAuth plugin is available through an async initialization
step because discovery is fetched before providers are registered:
let mut provider = GenericOAuthConfig::new("company-sso", client_id);
provider.client_secret = Some(client_secret);
provider.discovery_url =
Some("https://id.example.com/.well-known/openid-configuration".into());
config.add_plugin(
GenericOAuthPlugin::initialize(vec![provider]).await?
)?;Generic providers use only signIn.social and /callback/:id; there is no
generic-OAuth client plugin or plugin-specific route. GenericOAuthConfig
supports Better Auth 1.7.2 discovery, explicit endpoint precedence, stable
subject/issuer resolvers, PKCE, OIDC nonce/JWKS verification, every token
endpoint authentication method (including callback-driven
private_key_jwt), custom token/user/profile callbacks, static or
request-aware refresh parameters, provider logout, IDP-initiated restart, and
the signup/profile controls. The exported presets are auth0, gumroad,
hubspot, keycloak, line, microsoft_entra_id, okta, patreon,
slack, and yandex. Microsoft Entra's generic preset requires a concrete
tenant GUID; use the built-in Microsoft provider for common,
organizations, or consumers.
OAuth state is expiring and single-use. The default database strategy uses the
verification store plus a signed state cookie; the Better Auth encrypted
cookie strategy is selected with:
config.account.store_state_strategy = OAuthStateStrategy::Cookie;PKCE,
OIDC nonce, signature, issuer, audience, maximum-age, and redirect-URI checks
are provider-driven. Accounts use Better Auth 1.7's (issuer, accountId) key;
access and refresh tokens are stored as returned by default and use Better
Auth's randomized XChaCha20-Poly1305 hex envelopes only when
config.account.encrypt_oauth_tokens = true. ID tokens follow Better Auth and
remain unencrypted. The bound PostgreSQL schema uses issuer-qualified identity
exclusively; incompatible provider-qualified layouts are not migrated or read.
Linked-account policy lives under config.account.account_linking. Explicit
links require a provider-verified email unless the provider is trusted, require
the current user's email by default, and cannot unlink the final account unless
allow_unlinking_all is enabled. Provider-token reads and rotations remain
session-bound to the account owner; refresh rotation uses an atomic
compare-and-swap so concurrent requests return the winning token set.
Better Auth's optional encrypted account-data cookie is also supported. It is
disabled by default when using the database-backed account store. Enable it
when clients need explicit useAccountCookie: true selection:
config.account.store_account_cookie = true;Social sign-in and account linking select the provider account in
better-auth.account_data. getAccessToken, refreshToken, and accountInfo
accept that cookie only when the request explicitly selects it and an active
session belongs to the same user; the cookie is never a bearer credential.
The A256CBC-HS512 JWE uses Better Auth's better-auth-account salt, expires at
session.cookie_cache.max_age, refreshes with session/account changes, and is
cleared on session removal or a cross-user session switch. Oversized values use
Better Auth's numbered-cookie chunking and stale-chunk cleanup. Override its
name or scope with config.cookies.account_data.
Request rate limiting follows Better Auth's IP-and-path model. Release builds
enable the production default; debug builds mirror Better Auth development and
test mode by leaving it disabled unless explicitly enabled. Better Auth's
10-second/100-request global rule, stricter sign-in/sign-up/password/email
rules, plugin rules, ordered wildcard custom rules, and false-equivalent
path exclusions use the same precedence:
use lucid_auth::{RateLimitCustomRule, RateLimitStorageMode};
config.rate_limit.enabled = true;
config.rate_limit.window = 10;
config.rate_limit.max = 100;
config.rate_limit.storage = RateLimitStorageMode::Database;
config.rate_limit.custom_rules = vec![
RateLimitCustomRule::limit("/admin/*", 60, 20),
RateLimitCustomRule::disabled("/health"),
];Use RateLimitCustomRule::dynamic with a RateLimitRuleResolver when the
decision depends on the request method, normalized path, query, or headers; a
resolver returning None is Better Auth's functional false result.
Memory is the default for a single service process. Database uses the
configured AuthStore and PostgreSQL advisory locking for atomic limits across
instances. SecondaryStorage and Custom accept an Arc<dyn RateLimitStorage> whose single consume operation must atomically decide and
increment, matching Better Auth's storage hook. A rejected request returns only
{"message":"Too many requests. Please try again later."}, status 429, and
X-Retry-After in seconds. IP tracking disabled under
config.ip_address.disable_ip_tracking disables request limiting too; native
in-process AuthService calls are outside the HTTP limiter, matching Better
Auth server-side API behavior.
Captcha protection is an optional native server plugin. Choose one of the four closed Better Auth 1.7.2 provider variants and register it normally:
use lucid_auth::{CaptchaConfig, CaptchaPlugin, CloudflareTurnstileOptions};
config.add_plugin(CaptchaPlugin::new(CaptchaConfig::CloudflareTurnstile(
CloudflareTurnstileOptions::new(std::env::var("TURNSTILE_SECRET_KEY")?),
)))?;This protects /sign-up/email, /sign-in/email, and
/request-password-reset by default. Set a non-empty endpoints list to
replace those paths; * matches one path segment and ** matches nested
segments. An empty list restores the defaults. Provider verification always
times out after ten seconds and fails closed. Client code needs no Captcha
client plugin—pass the provider token through the ordinary request options:
await authClient.signIn.email({
email,
password,
fetchOptions: {
headers: { "x-captcha-response": captchaToken },
},
});Google reCAPTCHA supports min_score, expected_action, and
allowed_hostnames; Turnstile supports the latter two; hCaptcha and CaptchaFox
support site_key. All providers support replacement endpoints and a non-empty
site_verify_url_override. Client IPs come only from the shared
config.ip_address headers and trusted-proxy rules; the legacy
x-captcha-user-remote-ip header is ignored. See the
compatibility matrix
for the exact supported boundary and pinned upstream evidence.
i18n error messages are an optional native plugin matching
@better-auth/i18n@1.7.2. Select any built-in catalogs (or supply exact custom
locale/error-code maps), configure the ordered detection strategies, and
register it normally:
use lucid_auth::{
I18nConfig, I18nLocaleDetection, I18nLocales, I18nPlugin,
};
let translations = I18nLocales::selected(["en", "fr"]);
let mut i18n = I18nConfig::new(translations)?;
i18n.detection = vec![
I18nLocaleDetection::Cookie,
I18nLocaleDetection::Header,
];
i18n.locale_cookie = "locale".into();
config.add_plugin(I18nPlugin::new(i18n)?)?;The bundled I18nLocales contains the exact 22 published 1.7.2 catalogs. The
plugin translates only marked Better Auth API errors, preserving their status
and code while adding originalMessage; it does not translate arbitrary JSON,
successful responses, or OAuth protocol errors. Locale keys are matched
exactly, strategies run in order, and a selected locale missing an error-code
entry does not retry the default catalog. The official i18nClient() is
type-inference-only and needs no locale endpoint. See the
compatibility matrix
for the exact no-storage/no-locale-management boundary.
Register the server-only plugin to screen password hashes on Better Auth's seven official password-setting paths through the HIBP k-anonymity API:
use lucid_auth::{HaveIBeenPwnedOptions, HaveIBeenPwnedPlugin};
config.add_plugin(HaveIBeenPwnedPlugin::new(HaveIBeenPwnedOptions {
enabled: None,
paths: None,
custom_password_compromised_message: None,
}))?;Those are the complete Better Auth 1.7.2 options. Some(false) disables checks;
an explicitly supplied paths list replaces the defaults exactly, so an empty
list screens no paths; and an empty custom message falls back to Better Auth's
official message. Matching is exact and request-path scoped. Direct native hash
operations with no auth request path bypass the plugin, and sign-in/password
verification is never screened. The plugin adds no route, client, cookie,
schema, migration, middleware, or rate-limit rule. See the
compatibility matrix
for the exact request, parser, error, and route-side-effect contract.
Enable the crate's axum feature and register the server-only Open API plugin:
use lucid_auth::{AuthConfig, OpenApiPlugin};
let mut config = AuthConfig::new(std::env::var("BETTER_AUTH_SECRET")?.into_bytes())?;
config.set_base_url("https://auth.example.com")?;
config.add_plugin(OpenApiPlugin::default())?;The Scalar UI is then available at /api/auth/reference, and its JSON document
at /api/auth/open-api/generate-schema. Native tooling can retrieve the same
typed document without HTTP:
use lucid_auth::{AuthService, generate_open_api_schema};
let document = generate_open_api_schema(&service);
let json = serde_json::to_string_pretty(&document)?;The reference path, Scalar theme, CSP nonce, and UI availability use only the Better Auth 1.7.2 options:
use lucid_auth::{OpenApiConfig, OpenApiPlugin, OpenApiTheme};
config.add_plugin(OpenApiPlugin::new(OpenApiConfig {
path: "/docs".into(),
disable_default_reference: false,
theme: OpenApiTheme::Moon,
nonce: Some("request-csp-nonce".into()),
}))?;Set disable_default_reference to true to return Better Auth's empty JSON
404 from the UI route while keeping /open-api/generate-schema enabled. The
schema route is fixed, both plugin routes are hidden from their own document,
and Better Auth 1.7.2 provides no openAPIClient browser plugin. See the
compatibility matrix
for the exact generation boundary.
Create a separate auth service for integration tests and install the privileged server-only Test Utils plugin there. Do not add it to the production service:
use lucid_auth::{AuthConfig, AuthService, MemoryStore, TestUtilsPlugin};
use std::sync::Arc;
let store = Arc::new(MemoryStore::default());
let mut test_config = AuthConfig::new([7_u8; 32])?;
test_config.add_plugin(TestUtilsPlugin::default())?;
let test_auth = AuthService::new(store, test_config);The helper factory does not write until save_user, and login creates an
ordinary persistent session with a Better Auth-compatible signed cookie:
use lucid_auth::TestUserOverrides;
let test = test_auth.test().expect("Test Utils is installed");
let user = test.create_user(TestUserOverrides {
email: Some("integration@example.com".into()),
..TestUserOverrides::default()
});
let user = test.save_user(user).await?;
let login = test.login(user.id).await?;
// Pass login.headers["cookie"] to an ordinary auth request.
assert_eq!(login.cookies[0].domain, "localhost");Register TestUtilsPlugin::new(TestUtilsOptions { capture_otp: true }) to expose
the optional passive OTP view. Register the Organization plugin on the same
test service to expose the optional raw Organization fixture view. Test Utils
adds no route or remotely callable bypass by itself. It is the native equivalent
of testUtils() only; Better Auth's separate better-auth/test Node/Vitest
harness is outside the Rust server compatibility boundary. See the
compatibility matrix
for the exact surface.
Test Utils factories call the configured context ID generator first. A literal
defer/false result uses Better Auth's 24-character a-zA-Z0-9 factory
fallback; the ordinary default strategy returns its normal 32-character ID,
and Uuid returns a UUID. An empty callback string remains empty because it is
not literal false.
Username is an optional native plugin. Register it explicitly to add username fields to email signup and current-user updates and to mount the official username sign-in and availability routes:
config.add_plugin(UsernamePlugin::default())?;This route boundary is separate from AuthService::provision_password_user, so
closed-registration applications can still provision and authenticate native
username accounts without exposing Better Auth's public username plugin.
Last Login Method is also optional. It writes Better Auth's unsigned, browser-readable cookie only when an authentication response sets the primary session cookie:
config.add_plugin(LastLoginMethodPlugin::default())?;The exact default resolver recognizes email signup/signin, social and Generic
OAuth callbacks, SIWE, passkey verification, and magic-link verification. Set
custom_resolve_method to replace or extend that vocabulary, and use
before_store_cookie for an async consent decision. Returning None from the
custom resolver falls back to the defaults; returning an empty string suppresses
storage. Enable store_in_database to add the optional, input-disabled
lastLoginMethod user field and update it independently of cookie consent.
The bundled stores persist that logical field in existing user additional-field
storage, so this plugin has no standalone migration. The cookie is plaintext by
design; custom method names must not contain secrets or sensitive attributes.
cookie_name, floating-point max_age, and the user schema field name follow
Better Auth 1.7.2, including URI encoding and its 400-day cookie limit. The
official client reads and compares the cookie synchronously and can clear it;
its optional domain setting affects clearing only.
OAuth Popup is optional and reuses the configured social and Generic OAuth providers:
config.add_plugin(OAuthPopupPlugin)?;Use Better Auth's official 1.7.2 browser plugin:
import { createAuthClient } from "better-auth/client";
import { oauthPopupClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "https://auth.example.com",
plugins: [oauthPopupClient()],
});
await authClient.signIn.popup({
provider: "google",
callbackURL: "/dashboard",
});GET /oauth-popup/start validates the opener and callback URLs, creates the
ordinary OAuth state plus a signed oauth_popup marker, and redirects to the
provider. The callback retains its redirect and cookies but returns Better
Auth's pinned CSP-protected completion document, which posts the signed session
cookie value to the opener. Top-level use needs no other plugin. A client inside
a cross-origin iframe stores that value and sends it as a bearer session
credential, so embedded use additionally requires Bearer support ([#34]).
The marker intentionally is not bound into OAuth state, is not revalidated on
the callback, and uses one fixed cookie. Concurrent popup starts can therefore
overwrite each other's opener marker. Database OAuth state remains one-time;
encrypted cookie state keeps Better Auth's normal ten-minute replay window.
These are the Better Auth 1.7.2 protocol boundaries rather than extra Lucid
behavior. Per-cookie naming and attributes can be set with
config.cookies.plugin["oauth_popup"]; max_age and partitioned follow
Better Call's merge and serialization rules. Generated official-client popup
failures currently return their error code as the message, while the exported
error-code metadata still contains the descriptive text.
OAuth Proxy is an independent optional server plugin for preview and development deployments whose OAuth provider is configured to call only the production deployment:
use chrono::Duration;
use lucid_auth::{OAuthProxyConfig, OAuthProxyPlugin, OAuthProxySecret};
use url::Url;
config.add_plugin(OAuthProxyPlugin::new(OAuthProxyConfig {
current_url: Some(Url::parse("https://preview.example.com")?),
production_url: Some(Url::parse("https://auth.example.com")?),
max_age: Duration::seconds(60),
secret: Some(OAuthProxySecret::Plain(
std::env::var("OAUTH_PROXY_SECRET")?.into_bytes(),
)),
}))?;Register the same proxy secret in every participating deployment. It can be
separate from the global Better Auth secret; versioned proxy secrets are also
supported for rotation. The production deployment may use its production URL
as current_url, in which case the plugin detects matching origins and leaves
the ordinary flow unchanged. A non-empty x-skip-oauth-proxy request header
also opts one social sign-in out of proxying.
There is no oauthProxyClient. Applications keep using the ordinary official
client:
await authClient.signIn.social({
provider: "github",
callbackURL: "https://app.example.com/signed-in",
});On preview sign-in, the plugin keeps the original callback and OAuth state,
uses the production /callback/:provider URI for the provider, and replaces
the provider's state parameter with a shared-secret encrypted proxy package.
Production exchanges the authorization code and relays an encrypted user,
account, token, and callback profile to the preview deployment's
GET /oauth-proxy-callback. Preview validates the trusted callback, rejects a
payload older than max_age or over ten seconds in the future, consumes the
original OAuth state, creates the ordinary account/session, and redirects to
the state-bound callback or new-user URL.
Database-backed OAuth state is atomically consumed. Better Auth 1.7.2's cookie
state strategy only expires its response cookie and does not add a separate
server-side replay record. Request-derived preview origins are accepted only
when trusted; explicit current_url, supported hosting-platform URLs, and the
configured base URL provide the remaining upstream resolution order.
Matching Better Auth 1.7.2, OAuth Proxy forwards neither an OIDC nonce,
callback iss, OAuth device_id, nor a provider error_description across
the proxy hop. It adds no dedicated client factory, plugin-owned cookie,
schema, migration, rate limit, or error-code table; its only route is
GET /oauth-proxy-callback.
OAuth Provider is the independent authorization-server plugin matching
@better-auth/oauth-provider@1.7.2. The JWT plugin owns provider signing keys;
the OAuth Provider plugin owns its seven models, routes, rate limits, and schema:
use lucid_auth::{
AuthService, JwtPlugin, OAuthProviderPlugin, OAuthProviderPluginConfig,
};
use lucid_auth::postgres::PostgresStore;
use std::sync::Arc;
config.add_plugin(JwtPlugin::default())?;
config.add_plugin(OAuthProviderPlugin::in_memory(
OAuthProviderPluginConfig::new("/sign-in", "/oauth/consent"),
))?;Use the schema-aware PostgreSQL constructor in production, passing the same
cloneable store used by AuthService, and migrate the bound resolved schema
before serving:
let postgres_store = PostgresStore::new(pool, Default::default());
let provider_config = OAuthProviderPluginConfig::new(
"/sign-in",
"/oauth/consent",
);
config.add_plugin(OAuthProviderPlugin::postgres(
provider_config,
postgres_store.clone(),
)?)?;
let service = AuthService::try_new(Arc::new(postgres_store.clone()), config)?;
postgres_store
.migrate_all(&service.plugin_migrations())
.await?;The required pages receive Better Auth's signed oauth_query and must return
it through the provider's continue or consent methods.
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
export const authClient = createAuthClient({
baseURL: "https://issuer.example.com",
plugins: [oauthProviderClient()],
});Authorization code, client credentials, refresh tokens, OIDC, DPoP, resource indicators, discovery, registration, client/consent management, introspection, revocation, UserInfo, and logout follow the pinned plugin contract. Better Auth's server-only admin/resource actions intentionally remain unavailable over HTTP, and device authorization is a separate plugin. See the compatibility matrix for the precise boundary.
Client ID Metadata Document discovery matches the server-only
@better-auth/cimd@1.7.2 companion. Install it with OAuth Provider and supply
the required metadata-resource transport; there is no CIMD browser plugin,
client factory, HTTP endpoint, or Dynamic Client Registration alias:
use lucid_auth::{
CimdOptions, NativeCimdMetadataFetcher, cimd,
};
use std::sync::Arc;
config.add_plugin(cimd(CimdOptions::new(Arc::new(
NativeCimdMetadataFetcher,
)))?)?;The generic behavior follows Client ID Metadata Document draft-02. Set
metadata_profile to Some(CimdMetadataProfile::Mcp20260728) only for the
MCP 2026-07-28 draft-00 profile. Discovered clients use OAuth Provider's
configured oauthClient schema and canonical resource-link persistence; the
document cannot assign secrets, server trust, administrative flags, resources,
or client-credentials scopes.
The native transport accepts HTTPS only, resolves a hostname once, rejects the
request if any DNS answer is non-public, pins one approved address while
preserving Host/TLS identity, refuses redirects, and bounds response reads.
Applications supplying a custom transport must preserve those properties.
CIMD fetches untrusted remote metadata during OAuth requests, so production
operators should also use metadata_document_url_policy, conservative fetch
budgets, and an origin allowlist when their deployment has a narrower trust
boundary.
MCP support matches the authorization boundary of @better-auth/mcp@1.7.2.
It is an OAuth Provider preset and RFC 9728 protected-resource server, not an
MCP transport: it binds issued tokens to one configured MCP resource, links
newly registered clients to that resource, serves both root-mounted protected
resource metadata aliases, and verifies Bearer or DPoP credentials before an
application-owned MCP handler runs. It reuses OAuth Provider's seven models,
six rate limits, token/resource policy, and refresh rotation; the MCP preset
defaults the refresh-token retry overlap to 30 seconds, with explicit zero
restoring strict replay handling.
Install the preset instead of a separate OAuthProviderPlugin; its descriptor
remains oauth-provider, so it cannot be combined with another provider:
use lucid_auth::{
JwtPlugin, McpPlugin, McpPluginConfig, OAuthProviderPluginConfig,
};
config.add_plugin(JwtPlugin::default())?;
let provider = OAuthProviderPluginConfig::new("/sign-in", "/oauth/consent");
config.add_plugin(McpPlugin::in_memory(McpPluginConfig::new(
"https://api.example.com/mcp",
provider,
))?)?;Use McpPlugin::postgres with the same PostgresStore as AuthService in
production. It contributes the ordinary OAuth Provider schema and no
MCP-specific model.
There is no @better-auth/mcp/client export or MCP-specific Better Auth client
action. Use oauthProviderClient() for the inherited client-management
surface and version 2 of the official @modelcontextprotocol/client package
for MCP discovery, authorization, and protocol requests. The host application
owns its MCP HTTP POST route and transport. Lucid Auth does not add an MCP
session/SSE bridge, protocol-session store, database model, cookie, client
factory, or route-specific rate limit.
The configured resource must be one absolute URL without credentials, query,
or fragment. HTTPS is required except for localhost and numeric loopback
development URLs. Protected-resource metadata is publicly cacheable for 15
seconds and advertises the externally resolved OAuth issuer, resource scopes,
and Provider DPoP algorithms. Request challenges use the exact JSON-RPC error
shape and RFC 6750/RFC 9728 WWW-Authenticate parameters expected by the
official MCP client.
The convenience request verifier defaults issuer, JWKS, and audience from the auth base URL. That default audience deliberately does not infer the MCP preset's configured resource; pass the resource explicitly when they differ. The convenience path uses durable core verification reservations for DPoP replay protection. The lower-level verifier accepts explicit issuer/audience, local JWKS or remote introspection, scope policy, and a custom replay store.
Create one verifier with the service, then call it before dispatching each application-owned MCP request:
use lucid_auth::{
McpProtectedRequest, McpProtectedRequestOutcome, RequireMcpAuthOptions,
require_mcp_auth,
};
let verifier = require_mcp_auth(
service.clone(),
RequireMcpAuthOptions {
resource: Some("https://api.example.com/mcp".into()),
required_scopes: Some(vec!["mcp.read".into()]),
..Default::default()
},
)?;
match verifier.verify(&McpProtectedRequest {
authorization_header,
dpop_proof_jwt,
method: "POST".into(),
url: "https://api.example.com/mcp".into(),
}).await? {
McpProtectedRequestOutcome::Authorized(claims) => {
// Dispatch the JSON-RPC request with the verified claims.
}
McpProtectedRequestOutcome::Challenge(challenge) => {
// Return challenge.status_code, challenge.www_authenticate,
// challenge.content_type(), and challenge.json_rpc_body().
}
}Device Authorization is a separate plugin matching Better Auth 1.7.2. For a standalone first-party device flow, install it without OAuth Provider:
use lucid_auth::{DeviceAuthorizationConfig, DeviceAuthorizationPlugin};
config.add_plugin(DeviceAuthorizationPlugin::in_memory(
DeviceAuthorizationConfig::default(),
))?;Use the matching official client plugin. The standalone /device/token
exchange creates a first-party session and returns its bearer token without
setting a browser session cookie:
import { createAuthClient } from "better-auth/client";
import { deviceAuthorizationClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "https://auth.example.com",
plugins: [deviceAuthorizationClient()],
});For RFC 8628 token issuance by OAuth Provider, install the companion after the
provider and keep JwtPlugin for provider signing keys:
use lucid_auth::{
DeviceAuthorizationConfig, JwtPlugin, OAuthDeviceAuthorizationPlugin,
OAuthProviderPlugin, OAuthProviderPluginConfig,
};
config.add_plugin(JwtPlugin::default())?;
config.add_plugin(OAuthProviderPlugin::in_memory(
OAuthProviderPluginConfig::new("/sign-in", "/oauth/consent"),
))?;
config.add_plugin(OAuthDeviceAuthorizationPlugin::in_memory(
DeviceAuthorizationConfig::default(),
))?;import { createAuthClient } from "better-auth/client";
import {
oauthDeviceAuthorizationClient,
oauthProviderClient,
} from "@better-auth/oauth-provider/client";
export const authClient = createAuthClient({
baseURL: "https://issuer.example.com",
plugins: [oauthProviderClient(), oauthDeviceAuthorizationClient()],
});OAuth-owned device codes are exchanged at /oauth2/token; /device/token is
reserved for standalone codes and deliberately returns invalid_grant for
OAuth-owned codes. Both variants own a dedicated deviceCode model with atomic
claim and one-time redemption. In production, use DeviceAuthorizationPlugin::postgres
or OAuthDeviceAuthorizationPlugin::postgres with the same cloneable
PostgresStore passed to AuthService, then migrate the bound schema as in the
OAuth Provider example above.
Bearer session authentication is a separate, optional server plugin:
config.add_plugin(BearerPlugin::default())?;Better Auth 1.7.2 has no bearerClient() factory. Use the ordinary client fetch
configuration with the complete signed value returned in set-auth-token:
export const authClient = createAuthClient({
baseURL: "https://auth.example.com",
fetchOptions: {
auth: { type: "Bearer", token: storedSessionToken },
},
});By default the plugin accepts either an opaque database session token or the
signed Better Call cookie value. Set
BearerPlugin::new(BearerConfig { require_signature: true }) to accept only the
signed form. An accepted bearer credential takes precedence over a session
cookie even if its session no longer exists; an invalid signed credential is a
no-op and leaves cookie authentication available, matching upstream. Normal
session existence, expiry, revocation, cache binding, bans, and enabled plugin
policy still apply. Matching upstream hook ordering, a syntactically accepted
Bearer credential bypasses browser Origin and cross-site-navigation checks;
an invalid signed credential does not bypass those checks for a coexisting
cookie. The plugin never reads query/body tokens and does not add bearer-specific
JSON errors, WWW-Authenticate, routes, schema, migrations, or client metadata.
Responses that set a live primary session cookie expose its complete decoded
signed value through set-auth-token and
Access-Control-Expose-Headers; sign-out/expiry cookies do not.
JWT-plugin tokens are service tokens for external resource servers to verify against JWKS. Bearer does not accept those JWTs; it transports Better Auth session credentials. OAuth Popup uses this plugin only for a cross-origin embedded client that cannot rely on its partitioned browser cookie.
JWT is an independent optional plugin. Its default is Better Auth 1.7.2 EdDSA
with an Ed25519 key, a 15-minute token lifetime, GET /jwks, and authenticated
GET /token:
use lucid_auth::{JwkAlgorithm, JwtConfig, JwtPlugin};
let mut jwt = JwtConfig::default();
jwt.jwks.key_pair_config = Some(JwkAlgorithm::EdDsa);
config.add_plugin(JwtPlugin::new(jwt))?;Register jwtClient() from better-auth/client/plugins in the official
JavaScript client. Its token() method calls /token; jwks() calls the path
configured on the client plugin, which must match jwt.jwks.jwks_path on the
Rust server. Native server code uses service.jwt() for server-only signing,
verification, key creation, and exact key selection. The supported algorithms
are EdDSA/Ed25519, ES256, ES512, PS256, and RS256.
The plugin lazily creates signing keys and contributes its JWKS schema.
Private JWKs are encrypted by default with Better Auth's randomized
XChaCha20-Poly1305 format. AuthConfig::set_versioned_secrets enables $ba$
versioned envelopes and optional legacy bare-hex decryption during secret
rotation. Bind AuthService and run PostgresStore::migrate before serving;
memory storage needs no setup. Custom table/field names and independent
read/create adapter callbacks are available through JwtConfig.
Set jwt.session_cookie_cache = true together with
config.session.cookie_cache.strategy = CookieCacheStrategy::Jwt to replace
the ordinary HS256 cache token with Better Auth's asymmetric, JWKS-verifiable
session-cache profile. Remote jwt.sign cannot be combined with this mode.
jwks.remote_url makes the local JWKS route return 404 and requires an explicit
primary algorithm for discovery metadata. JWT responses are no-store, and
only public JWK fields are returned over HTTP.
One-Time Token is an independent optional plugin for transferring an existing session to another browser, device, or domain:
config.add_plugin(OneTimeTokenPlugin::default())?;import { createAuthClient } from "better-auth/client";
import { oneTimeTokenClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "https://auth.example.com",
plugins: [oneTimeTokenClient()],
});
const { data: generated } = await authClient.oneTimeToken.generate();
await authClient.oneTimeToken.verify({ token: generated.token });Generation requires an ordinary session. Tokens default to 32 random
characters, expire after three minutes, and are consumed atomically before the
referenced session is checked. Storage is plaintext by Better Auth default;
select OneTimeTokenStorage::Hashed or provide a custom async hasher when the
adapter must not contain the raw transfer token. A custom async generator,
server-only HTTP generation, verification without setting a session cookie,
and automatic set-ott headers on responses that bind a session are available
through OneTimeTokenConfig.
The token is a portable bearer credential, not a purpose- or user-bound proof.
Redemption returns and optionally binds the originating session even when the
browser already has a different session. It has no payload, origin, IP, or
freshness policy. Matching Better Auth 1.7.2 exactly, redemption burns the token
before session lookup; missing and expired sessions therefore cannot be retried.
The pinned implementation also queues the referenced session cookie before its
expired-session rejection and can issue a successor set-ott header when that
hook is enabled. Applications should use short expiries, hashed storage, secure
transport, and avoid enabling the response header unless that exchange flow is
required.
Lucid deliberately does not reproduce five pinned 1.7.2 bugs: an expired/null
session cannot receive set-auth-jwt; schema remapping is instance-local;
token responses and errors are never cacheable; private JWKs are redacted from
ordinary diagnostics even when storage encryption is disabled; and service
token signing fails unless issuer/audience can be resolved safely. These are
security/correctness fixes, not legacy modes or compatibility aliases.
Additional fields for Better Auth's user, session, account, and verification
models are explicit and typed. Plugin schema descriptors are merged in the order
plugins are supplied; each core model then applies its core fields, those merged
plugin fields, and finally the host's additional fields. The result is available
through AuthService::database_schema_fields.
Client input validation runs before hooks. The adapter phase then applies
defaults and input transforms once to the final shallow-patched data; updates
also apply on_update_with. Responses apply returned/output policy.
Core IDs, tokens, ownership, timestamps, expiry, and input-disabled fields are
never writable. Set returned(false) for persisted server-only values:
config.user.additional_fields.insert(
"timezone".into(),
AdditionalField::new(AdditionalFieldType::String).default_value(json!("UTC")),
);
config.session.additional_fields.insert(
"theme".into(),
AdditionalField::new(AdditionalFieldType::String).optional(),
);
config.account.additional_fields.insert(
"tenantReference".into(),
AdditionalField::new(AdditionalFieldType::String).optional(),
);
config.user.additional_fields.insert(
"managedFlag".into(),
AdditionalField::new(AdditionalFieldType::Boolean)
.optional()
.input(false)
.returned(false),
);PostgreSQL creates each configured additional field as its own resolved physical column, including model/field remaps, references, and indexes. There is no catch-all JSONB persistence column or legacy fallback.
Set AuthConfig::database_hooks for host hooks or implement
AuthPlugin::database_hooks for plugin hooks. Before hooks run in plugin
dependency order and then host order; they can continue, shallow-merge a
partial top-level patch, or cancel. Explicit null and undefined values overwrite
earlier values, while nested objects replace instead of recursively merging.
There is no whole-record replacement alias. A cancellation or error prevents
the authoritative write. After hooks run in the same order only after
persistence has committed, so an after-hook error is reported but does not roll
the write back.
DatabaseHookContext::transaction exposes the active canonical-logical adapter
view for reentrant reads and writes. User/account creation inserts the user,
passes its adapter-returned string ID to the account hook, inserts the account,
and commits both before either after hook runs. Memory stages an isolated copy;
PostgreSQL reuses the current SQL transaction and connection, including with a
one-connection pool. Neither adapter retries after hook execution begins.
Custom AuthStore implementations provide this boundary through
AuthStore::transaction, DatabaseTransaction, and
DatabaseTransactionOperation; run_database_transaction is the typed helper.
HTTP calls include method, path, query, and headers in DatabaseHookContext;
native calls have no request. run_in_background schedules non-authoritative
follow-up work outside the authoritative transaction. Update hooks may not
change protected identity, ownership, or creation fields.
Email changes are disabled by default, matching Better Auth. Enable the verified flow with the existing verification-email sender:
config.user.change_email.enabled = true;
config.email_verification.sender = Some(Arc::new(MyVerificationSender));For an unverified current address, setting
update_email_without_verification = true changes it immediately and then
sends normal verification when a sender is configured. For verified accounts,
the default sends verification to the new address. Configure
send_change_email_confirmation to require approval from the current address
before the new-address verification is sent. Email normalization, uniqueness,
stateless signed tokens, callback URLs, and session-cookie refresh are enforced
in every mode. The two current phases use the exact
change-email-confirmation and change-email-verification claims; the Better
Auth 1.7.2 legacy token branch remains distinct.
Better Auth anonymous users are an optional plugin and their routes and
isAnonymous user field are absent until it is registered:
config.add_plugin(AnonymousPlugin::new(AnonymousPluginConfig {
email_domain_name: Some("guests.example.com".into()),
..AnonymousPluginConfig::default()
}))?;The plugin supports signIn.anonymous and deleteAnonymousUser, configurable
name/email generators, deletion policy, and a typed on_link_account callback.
Successful email/password, username, and social sign-ins atomically claim the
anonymous upgrade, invoke the callback once, and clean up the anonymous user
and all of its sessions. Abandoned or concurrent attempts cannot invoke the
callback twice.
Guest capability grants are a lucid-auth extension, not part of Better Auth's
Anonymous plugin lifecycle. They are therefore absent by default and are never
claimed or deleted by anonymous-account conversion. Register the optional
plugin with its extension store to mount /guest-grants,
/guest-grants/revoke, and /sign-in/guest-grant:
let store = Arc::new(MemoryStore::default());
config.add_plugin(AdminPlugin::new(OwnerPolicyPlugin::admin_config()))?;
config.add_plugin(OwnerPolicyPlugin)?;
config.add_plugin(GuestCapabilityPlugin::new(store.clone()))?;
let auth = AuthService::new(store, config);The bearer token is returned only when a grant is issued. Native hosts can use
AuthService::guest_capability_principal to obtain its permissions and resource
scopes. A custom browser client can call the plugin route directly:
await fetch("/api/auth/sign-in/guest-grant", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});For PostgreSQL, pass AuthService::plugin_migrations() to
PostgresStore::migrate_all. The bound Better Auth schema does not include
guest-capability tables unless this Lucid extension is registered.
Product security auditing is another optional lucid-auth extension. Core stores
have no audit methods, the bound Better Auth schema creates no audit table, and
/access/audit is absent unless AuditPlugin is registered. Memory-backed
applications provide a separate sink:
let auth_store = Arc::new(MemoryStore::default());
let audit_store = Arc::new(MemoryAuditStore::default());
config.add_plugin(AdminPlugin::new(OwnerPolicyPlugin::admin_config()))?;
config.add_plugin(OwnerPolicyPlugin)?;
config.add_plugin(AuditPlugin::new(audit_store).with_max_events(10_000))?;
let auth = AuthService::new(auth_store, config);For PostgreSQL, pass the same Arc<PostgresStore> to AuditPlugin and apply the
service's plugin migrations. The plugin owns its table, retention operation,
and owner-only listing route. Both bundled stores return newest-first events,
ordering equal timestamps by event ID. Recording is deliberately fail-open: a
sink failure never rolls back a completed authentication or administrative
write, while an explicit audit-list request reports sink errors. User deletion
anonymizes actor and subject references. AuditMetadata recursively rejects
password-, cookie-, token-, OTP-, secret-, challenge-, API-key-, and
credential-bearing field names, including authorization and bearer fields; the
same validation runs during deserialization.
Audit action vocabulary version 2 contains operator_security.owner_recovered,
user.created, user.role.changed, user.banned, user.unbanned,
user.removed, password.changed, password.reset_by_owner,
session.revoked, session.user_revoked, session.others_revoked,
session.all_revoked, impersonation.started, impersonation.stopped,
passkey.enrolled, passkey.renamed, passkey.deleted,
step_up.recovery_codes.generated, step_up.recovery_code.used,
guest_grant.issued, guest_grant.redeemed, and guest_grant.revoked. This
native vocabulary is not Better Auth Infrastructure Dashboard audit-log
compatibility.
Passkey is also optional. Register it explicitly; without the plugin, its seven routes do not exist:
let passkeys = PasskeyConfig {
rp_id: Some("example.com".into()),
rp_name: Some("Example".into()),
origins: Some(vec!["https://app.example.com".into()]),
..PasskeyConfig::default()
};
config.add_plugin(PasskeyPlugin::new(passkeys))?;origins: None uses the verification request's Origin, matching Better Auth;
an explicit vector accepts any configured origin. Registration supports the
official name, context, authenticatorAttachment, authenticator-selection,
extension, fresh-session, createSession, and passkey-first resolveUser
semantics through native Rust configuration and callbacks. The official client
schema includes publicKey, exact credentialID, counters, device type, backup
state, transports, and AAGUID. Challenges are durable and single-use, while
signature counters use compare-and-swap persistence.
Role-driven passkey assurance, step-up enforcement, and the associated recovery
codes are provided only by the optional StepUpPolicyPlugin; they are not
Better Auth passkey behavior. Core password sign-in always returns a normal
Better Auth session, and core session JSON contains neither assurance nor
stepUpRequired.
let auth_store = Arc::new(MemoryStore::default());
let step_up_store = Arc::new(MemoryStepUpStore::default());
config.add_plugin(StepUpPolicyPlugin::new(
auth_store.clone(),
step_up_store,
StepUpPolicyConfig {
required_roles: vec!["admin".into()],
..StepUpPolicyConfig::default()
},
))?;
let auth = AuthService::new(auth_store, config);The plugin protects only the configured roles; its neutral default protects no
roles. OwnerPolicyPlugin::step_up_config() supplies the fixed-policy owner
preset. Step-Up freshness defaults to one day after a passkey, two-factor, or
recovery-code verification. It owns its state and recovery-code
storage, contributes its PostgreSQL migration, composes independently with
PasskeyPlugin and TwoFactorPlugin, and exposes recovery operations through
AuthService::step_up_policy. Its typed session_projection is the native host
view of assurance, freshness, and whether step-up is required. Enabling the
plugin invalidates pre-existing sessions for required roles because those
sessions have no authenticated plugin state. The plugin intentionally adds no
Better Auth routes or response fields; applications that want browser-visible
prompts must provide their own extension client.
Sole-owner recovery and custom owner policy are separate optional project extensions documented in #73 and #75. Better Auth passkey endpoints do not impose those policies.
Two-Factor Authentication is an independent optional plugin. Memory-backed applications provide a separate factor store and an OTP delivery callback:
#[async_trait]
impl TwoFactorOtpSender for MyOtpSender {
async fn send(&self, message: TwoFactorOtp) -> Result<(), AuthError> {
deliver_code(&message.user, &message.code).await
}
}
let factors = Arc::new(MemoryTwoFactorStore::default());
let mut two_factor = TwoFactorConfig::default();
two_factor.issuer = Some("Example".into());
two_factor.otp = Some(OtpConfig::new(Arc::new(MyOtpSender)));
config.add_plugin(TwoFactorPlugin::new(factors, two_factor))?;PostgreSQL applications pass the same Arc<PostgresStore> used for core auth
and migrate the schema after binding AuthService. The configured factor model
is absent when the plugin is disabled. The official
twoFactorClient enable/disable, TOTP, OTP, and backup-code methods then work
without a custom browser transport. AuthService::generate_two_factor_totp and
AuthService::view_two_factor_backup_codes are trusted server-only equivalents
of Better Auth's server APIs and must never be exposed without application-level
authorization.
TOTP secrets and backup-code lists use authenticated encryption at rest. OTPs
are persisted only as one-way hashes, TOTP counters and backup-code replacements
are atomic, sign-in challenges have a five-attempt budget, consecutive factor
failures lock the account by default, and trusted-device records rotate on use
and expire after 30 days. Configure those durations and budgets through
TwoFactorConfig; disabling the plugin removes all two-factor routes and its
twoFactorEnabled user field.
Core email/password authentication is disabled by default, matching Better
Auth. Enable it with config.email_and_password.enabled = true; the same
configuration exposes signup enablement, auto-sign-in, verification-required
mode, and password length bounds. Email identities are stored lowercase and
enforced case-insensitively by both adapters. Wire input accepts Better Auth's
exact callbackURL spelling only.
Email delivery is supplied in-process by implementing
VerificationEmailSender and assigning it to
config.email_verification.sender. The callback receives a typed
VerificationEmail containing the user, verification URL, and signed token.
Configure AuthConfig::set_base_url as well so delivered links use the public
authentication origin and base path.
send_on_sign_up, send_on_sign_in, auto_sign_in_after_verification, and
expires_in mirror Better Auth's verification lifecycle. Core email links are
stateless HS256 JWTs signed with the current configured secret. Newly issued
tokens contain lowercase email, numeric iat/exp, and only the
{"alg":"HS256"} protected header; they create no verification record.
Expiry has no clock leeway, while the shipped 1.7.2 verifier accepts a validly
signed token with absent temporal claims and validates them when present.
Password reset delivery is supplied by implementing PasswordResetEmailSender
and assigning it to config.email_and_password.send_reset_password. The sender
receives the user, reset URL, and one-time token. The default expiry is one hour;
reset_password_token_expires_in, revoke_sessions_on_password_reset, and the
native async on_password_reset callback mirror Better Auth's lifecycle options.
Reset requests use a 24-character a-zA-Z0-9 token and accept Better Auth's
exact redirectTo field, while the emailed
callback endpoint accepts exact callbackURL; incorrectly cased aliases are not
supported. The complete reset-password:<token> identifier is processed once
through the configured plain/hashed/custom verification storage. Password
replacement and single-use token consumption are atomic; on_password_reset
runs before optional session revocation.
Current-user deletion is disabled by default. Enable it with
config.user.delete_user.enabled = true. Better Auth's password and fresh-session
flows then work immediately; configure a native
DeleteAccountVerificationSender to require a purpose-bound, single-use email
token instead. before_delete and after_delete callbacks compose with plugin
user-deletion hooks, and successful deletion clears the session cookie and all
adapter-owned account data. Deletion links and requests accept only the exact
callbackURL spelling.
Core session credentials are always 32 characters from a-zA-Z0-9, independent
of session database IDs. Deletion verification uses 32 lowercase-alphanumeric
characters and the complete delete-account-<token> identifier, consuming the
record before checking its user binding. Upgrading from the earlier native token
formats intentionally invalidates existing sessions, reset links, persisted
email links, and deletion links; no legacy lookup aliases are retained.
Magic Link is an optional native plugin. Implement MagicLinkSender, construct
MagicLinkConfig, and register MagicLinkPlugin with AuthConfig::add_plugin.
Its two routes work with Better Auth 1.7.2's official magicLinkClient:
let mut magic_link = MagicLinkConfig::new(Arc::new(MyMagicLinkSender));
magic_link.token_storage = MagicLinkTokenStorage::Hashed;
config.add_plugin(MagicLinkPlugin::new(magic_link))?;The default five-minute link is purpose-bound, atomically single-use, and uses
Better Auth's plain-token storage default; select Hashed or provide a native
custom hasher when persisted token secrecy is required. Delivery receives the
email, verification URL, token, metadata, and a narrowed request context.
callbackURL, newUserCallbackURL, and errorCallbackURL use Better Auth's
exact casing, and all redirects pass the configured trusted-origin policy.
Email OTP is also optional. Implement EmailOtpSender and register the plugin;
the official Better Auth 1.7.2 emailOTPClient then supports verification,
passwordless sign-in/signup, password reset, and configured email changes:
let mut email_otp = EmailOtpConfig::new(Arc::new(MyEmailOtpSender));
email_otp.storage = EmailOtpStorage::Hashed;
email_otp.change_email.enabled = true;
config.add_plugin(EmailOtpPlugin::new(email_otp))?;Defaults match Better Auth: six numeric digits, a 300-second expiry, three
attempts, rotating resends, plain storage, disabled signup-triggered delivery,
and disabled email change. Select Hashed, Encrypted, or a custom storage
adapter when persisted OTP secrecy is required. Successful redemption is
atomic; unknown-user verification and reset sends remain enumeration-safe.
send_verification_on_sign_up and override_default_email_verification mirror
the Better Auth plugin options. Native code can also call create_email_otp and
get_email_otp, corresponding to Better Auth's server-only APIs.
Phone Number is an optional native plugin. Supply the same memory, SQLite, or PostgreSQL
store used by AuthService, an OTP sender, and—when OTP verification may create
users—a temporary-email resolver:
let phone_number = PhoneNumberConfig {
send_otp: Some(Arc::new(MyPhoneOtpSender)),
send_password_reset_otp: Some(Arc::new(MyPhoneResetOtpSender)),
sign_up_on_verification: Some(PhoneNumberSignUpConfig {
temporary_email: Arc::new(MyTemporaryPhoneEmail),
temporary_name: None,
}),
..PhoneNumberConfig::default()
};
config.add_plugin(PhoneNumberPlugin::new(store.clone(), phone_number))?;The official Better Auth 1.7.2 phoneNumberClient supports opaque phone strings
by default; format validation is opt-in through PhoneNumberValidator. Its
defaults are six numeric digits, a 300-second expiry, three attempts, optional
signup after verification, and password-based signIn.phoneNumber. OTP
verification is the passwordless session/signup flow. The plugin also implements
password-reset SMS delivery, authenticated phone replacement, atomic uniqueness,
custom schema field names, and the native server-only consume_phone_number_otp
API. updateUser may clear
phoneNumber with null, which also clears verification, but cannot set or
replace it directly. PostgreSQL deployments migrate the bound service schema so
the unique phone-number index is present.
Google One Tap is an optional native plugin. Give OneTapConfig a Google web
client ID, or omit it to reuse the client ID from a registered Google social
provider:
let mut google = BuiltinProvider::new(
BuiltinProviderKind::Google,
std::env::var("GOOGLE_CLIENT_ID")?,
std::env::var("GOOGLE_CLIENT_SECRET")?,
);
google.config_mut().hosted_domain = Some("example.com".into());
config.add_social_provider(google)?;
// When omitted, the registered Google provider's client ID is used.
let one_tap = OneTapConfig::default();
config.add_plugin(OneTapPlugin::new(one_tap))?;Register Better Auth 1.7.2's client plugin with the same Google web client ID:
import { createAuthClient } from "better-auth/client";
import { oneTapClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "https://auth.example.com",
plugins: [oneTapClient({ clientId: googleClientId })],
});
await authClient.oneTap({ callbackURL: "/dashboard" });The plugin ID is one-tap, the client factory/action are
oneTapClient/oneTap, and the only server route is
POST /one-tap/callback under the configured auth base path.
The official client loads Google Identity Services, renders or prompts in the
browser, and enables FedCM by default; One Tap is therefore browser-only and an
SSR invocation intentionally does nothing. promptOptions.fedCM: false selects
the non-FedCM prompt behavior supported by the official client. Prompt mode
retries with a one-second base delay for up to five attempts by default, while
button mode renders Google's button instead. The official client also prevents
silent Google access after sign-out. An action-level nonce is forwarded to
Google Identity Services only; Better Auth 1.7.2 does not send it to or validate
it at the callback route.
callbackURL is validated by the server's trusted-origin policy, but the
callback response is { token, user } and the server never redirects. After a
successful callback, the official browser client performs the navigation.
hosted_domain enforces Google's hd claim for both Google OAuth and One Tap;
use "*" to require any non-empty hosted-domain claim. One Tap otherwise uses
the normal Google account linking, signup, session, anonymous-upgrade, and
email-verification policies. The plugin adds no schema, migration, cookie, or
plugin-specific rate-limit declaration.
Sign In With Ethereum is an optional native plugin. Supply the shared memory or PostgreSQL store, a nonce generator, and the application-specific Ethereum signature verifier:
let mut siwe = SiweConfig::new(
"example.com",
Arc::new(MySiweNonceGenerator),
Arc::new(MySiweMessageVerifier),
);
siwe.email_domain_name = Some("example.com".into());
siwe.ens_lookup = Some(Arc::new(MyEnsLookup));
config.add_plugin(SiwePlugin::new(store.clone(), siwe))?;SiweNonceGenerator must return 8–250 ASCII alphanumeric characters.
SiweMessageVerifier receives the original message and signature, EIP-55
checksummed address, numeric chain ID, and Better Auth's CAIP-122 projection.
The nonce is stored for 15 minutes and is consumed as soon as a syntactically
valid nonce is parsed, before domain, address, chain, time, or signature checks.
This ordering and the deliberately narrow message parser match Better Auth
1.7.2 exactly.
Use the official client without a Lucid-specific adapter:
import { createAuthClient } from "better-auth/client";
import { siweClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: "https://auth.example.com",
plugins: [siweClient()],
});
const { data: nonce } = await authClient.siwe.nonce();
const result = await authClient.siwe.verify({ message, signature });The plugin exposes POST /siwe/nonce, its POST /siwe/get-nonce alias, and
POST /siwe/verify. Verification returns exactly
{ token, success: true, user: { id, walletAddress, chainId } } and creates a
normal session. Anonymous mode is enabled by default
and generates the same wallet-derived email shape as Better Auth; disabling it
requires a valid email. A wallet seen on another chain reuses its existing
user and adds a non-primary wallet/account identity. PostgreSQL deployments
migrate the bound service schema, including the configured wallet-address
model.
Organization is an optional native plugin. Its store is independent from the core authentication store and can use memory, SQLite, or PostgreSQL:
let organizations = Arc::new(MemoryOrganizationStore::default());
let organization = OrganizationPluginConfig {
teams: OrganizationTeamsConfig {
enabled: true,
..OrganizationTeamsConfig::default()
},
dynamic_access_control: OrganizationDynamicAccessControlConfig {
enabled: true,
..OrganizationDynamicAccessControlConfig::default()
},
..OrganizationPluginConfig::default()
};
config.add_plugin(OrganizationPlugin::with_config(organizations, organization))?;The plugin implements every Better Auth 1.7.2 organizationClient method for
organizations, active state, members, invitations, teams, permissions, and
dynamic roles. Limits and last-owner rules are enforced atomically. Invitation
delivery, creation policy, and all documented organization/member/invitation/team
lifecycle hooks have native async traits. SQLite and PostgreSQL users pass the
shared database store and migrate the resolved schema after binding the service.
API Key is an optional native plugin. Register it explicitly; without the plugin, its routes and PostgreSQL table do not exist:
let api_keys = ApiKeyConfiguration {
enable_metadata: true,
enable_session_for_api_keys: true,
..ApiKeyConfiguration::default()
};
config.add_plugin(ApiKeyPlugin::new(api_keys))?;The official apiKeyClient create/get/list/update/delete methods work against
the Better Auth 1.7.2 paths and schemas. Server-only verification and expired-key
cleanup remain native AuthService methods and are not mounted as HTTP routes.
Secrets use Better Auth's 64-character
letter-only default generator, optional prefixes, and SHA-256 base64url hashing;
only creation returns the plaintext key. Stored hashes never appear in get,
list, update, or verify responses. Ownership and configId are enforced for
management operations. Quota and rate-limit claims are atomic in database and
database-fallback modes in both the memory and PostgreSQL stores. Better Auth's
secondary-storage-only mode deliberately uses a non-atomic read/merge/write
snapshot, so concurrent processes can oversubscribe a quota or rate window.
Set enable_session_for_api_keys to accept the configured headers (default
x-api-key) as Better Auth sessions. Header arrays, synchronous custom getters,
async validators, callback order/count, multiple named configurations, custom
key generation, starting-character display, expiry bounds/defaults, metadata,
permissions, refills, and per-key rate limits match @better-auth/api-key@1.7.2.
Set storage to ApiKeyStorage::SecondaryStorage for the exact secondary-only
record keys and serialization; set fallback_to_database for Better Auth's
database-authoritative read-through cache. custom_storage takes precedence
over the service-wide secondary store, and defer_updates makes secondary-only
usage writes and invalid-key deletion eventually consistent.
disable_key_hashing stores bearer secrets in plaintext and materially worsens
the impact of a database or cache breach. API-key-backed sessions impersonate
the owning user and are not recommended as a general production session
mechanism. Deferred updates can expose stale state, and the secondary-only
reference-list lock is process-local rather than distributed. Set a named
configuration's reference to ApiKeyReference::Organization to require the
Organization plugin and enforce its apiKey create/read/update/delete
permissions. The pinned oracle and native storage/request contracts are tracked
by #76.
Native plugins implement AuthPlugin and are registered with
AuthConfig::add_plugin. Construct plugin-enabled services with
AuthService::try_new so invalid IDs, missing or cyclic dependencies,
conflicts, duplicate/core route ownership, cookie collisions, migration IDs,
rate limits, middleware declarations, and false or incomplete provenance claims
fail before the router starts. Every PluginDescriptor is explicitly either a
PinnedBetterAuthPort, with exact upstream server artifact identity, or a
LucidExtension, which makes no Better Auth compatibility claim. Official
upstream client metadata is separate from server identity; application-authored
client metadata cannot be reported as upstream evidence. Plugin routes remain
inside the normal origin/CORS security boundary, while plugin middleware is
scoped to the routes that plugin owns. Session lifecycle hooks run in validated
dependency order.
This is an in-process native Rust composition boundary, not a JavaScript plugin runtime, community SDK, registry, certification program, or marketplace. Arbitrary Better Auth npm/JavaScript plugins do not execute in Rust.
PostgreSQL hosts first bind the complete Better Auth schema through
AuthService, then migrate that resolved schema plus any Lucid extension
operations:
let report = store.migrate_all(&service.plugin_migrations()).await?;
assert!(report.compatible);Lucid extension operations are keyed by (plugin_id, migration_id), share the
schema advisory lock, and are transactional and idempotent. Official Better
Auth plugins contribute schema instead of replayable SQL. See the
native plugin example for a route, middleware,
migration, cookie/rate-limit declarations, and application-owned client
metadata. The example is a project extension and is not an official Better Auth
plugin or client.
PostgresStore::migration_plan derives deterministic tables, columns/types,
and explicit indexes directly from the schema already bound by AuthService.
Only Lucid extension operations appear in its migration descriptors.
diagnose_schema is a read-only in-process catalog check for pending or changed
extension operations and missing/mistyped physical objects. Reports contain
only operation/object identifiers and never receive or serialize a database
URL.
The Better Auth Admin surface is absent unless AdminPlugin is registered:
config.add_plugin(AdminPlugin::default())?;It uses AdminConfig and defaults to the official admin and user roles.
AdminRole::allow defines custom resource/action
statements, admin_user_ids grants access independently of role, and
default_role, ban defaults/message, and impersonation duration mirror the
documented plugin options. Administrator impersonation remains disabled unless
allow_impersonating_admins is enabled or a custom role grants
user:impersonate-admins. Role arrays are stored as Better Auth's comma-joined
role value. The official client can create passwordless users, preserve
additional fields, query and update users, check permissions, manage bans and
sessions, and enter or stop bounded impersonation sessions.
Without AdminPlugin, Admin routes are not mounted and Admin's role, banned,
banReason, and banExpires user fields are omitted. Core logic does not
interpret those fields. To opt into lucid-auth's fixed owner/member/viewer
product policy, compose the separate host-policy plugin with its exact Admin
configuration:
config.add_plugin(AdminPlugin::new(OwnerPolicyPlugin::admin_config()))?;
config.add_plugin(OwnerPolicyPlugin)?;OwnerPolicyPlugin alone is rejected, as is pairing it with a different Admin
role configuration. It owns the fixed role vocabulary, owner-only gates,
last-owner invariant, owner-promotion session revocation, and owner-oriented
defaults used by Guest Capability, Audit, and Operator Security. Generic Admin
does not retain any of those rules as compatibility aliases.
For an existing PostgreSQL installation, make an explicit migration choice before serving traffic:
- To retain existing
owner,member, andviewervalues, register the exact pair above and apply the full bound schema plus enabled Lucid extension operations. - To adopt Better Auth Admin directly, register
AdminPluginwith roles that match the values you intentionally keep, or rewrite persisted role values to the configured Better Auth roles in an application migration. - To run core-only, register neither plugin. The bound schema, HTTP responses, and principals omit Admin-only fields; lucid-auth does not read compatibility aliases for a previous shape.
The bundled stores physically colocate Better Auth Admin values with their user
records for atomic reads; AdminPlugin is their sole behavioral owner. The
owner-policy plugin adds no duplicate role or ban storage.
Managed temporary passwords and local sole-owner recovery are optional lucid
operator policy, not Better Auth Admin behavior. Default and Admin-only user
responses contain no mustChangePassword field, and creating or resetting a
user password does not silently restrict that account.
Register OperatorSecurityPlugin to opt into administrator-issued temporary
credentials and native recovery:
let store = Arc::new(MemoryStore::default());
config.add_plugin(AdminPlugin::new(OwnerPolicyPlugin::admin_config()))?;
config.add_plugin(OwnerPolicyPlugin)?;
config.add_plugin(OperatorSecurityPlugin::new(
store.clone(),
OperatorSecurityConfig::default(),
))?;
let auth = AuthService::new(store, config);The plugin exposes temporary-credential status separately from Better Auth user
JSON. AuthService::principal and sensitive plugin hooks reject access until
the official change-password flow clears the plugin state. Provisioned bootstrap
passwords can opt into the same policy through OperatorSecurityConfig.
AuthService::operator_security().local_recover_sole_owner is an explicitly
out-of-band native primitive for a host CLI. It atomically refuses multi-owner
installations, replaces the sole owner's password, clears bans, sessions,
passkeys, API keys, and enabled factor-plugin state, marks the replacement
temporary, and records an actorless audit event when AuditPlugin is enabled.
The operator plugin contributes no HTTP endpoint. Its PostgreSQL extension
operation owns the temporary-password table; no compatibility column or alias is
part of the Better Auth schema.
WebAuthn relying-party and origin configuration lives on PasskeyConfig.
Registration and authentication challenges are stored through the configured
backend, expire after five minutes, and are atomically consumed once, including
across service instances.
Cookie-authenticated browser mutations require a trusted Origin or Referer
and reject cross-site navigation login attempts. Same-origin requests are
matched against the request host. Add an explicit cross-origin frontend with
AuthConfig::trust_origin. It follows Better Auth's pattern rules: exact
HTTP(S) origins, host or full-origin */? globs such as
https://*.example.com and http://localhost:*, and path-pinned custom schemes
are supported. The exact Better Auth redirect fields (callbackURL,
redirectTo, errorCallbackURL, and newUserCallbackURL) must contain an
accepted relative path or use a trusted origin.
Client IPs come from Axum's transport ConnectInfo, never from an unverified
forwarding header. Serve the router with
into_make_service_with_connect_info::<std::net::SocketAddr>(). Deployments
behind a reverse proxy must add its exact address or CIDR with
config.ip_address.trust_proxy(...); forwarding headers are then walked from
the trusted edge to the first untrusted hop. ip_address_headers,
ipv6_subnet, and disable_ip_tracking correspond to Better Auth's advanced
IP-address options. Better Auth's separate trustedProxyHeaders URL behavior is
disabled by default; set config.trusted_proxy_headers = true only when a
trusted edge overwrites both x-forwarded-host and x-forwarded-proto.
Routes mount at /api/auth by default. AuthConfig::set_base_url and
set_base_path configure HTTPS production origins and custom mounts; an HTTPS
base URL also selects Better Auth's __Secure- cookie names. CookieConfig
supports the Better Auth prefix, per-cookie names and attributes, and optional
cross-subdomain scope. Call AuthConfig::enable_cors to answer credentialed
preflights for trusted origins; untrusted origins remain blocked by the same
CSRF policy.
Session storage and cookie caching follow Better Auth's separate primary-token
and session_data design. The default remains database-backed with cookie cache
disabled. To enable the default compact cache:
config.session.cookie_cache.enabled = true;Set CookieCacheStrategy::Jwt for HS256 or CookieCacheStrategy::Jwe for
Better Auth's HKDF-derived A256CBC-HS512 encrypted profile. max_age,
refresh_cache, and version correspond to Better Auth's cookieCache
settings; changing version invalidates existing caches. Large cache values are
split into Better Auth-compatible numbered cookies.
Database and secondary-backed sessions slide at Better Auth's one-day
updateAge by default. Set config.session.update_age, or set
disable_session_refresh to suppress automatic writes. With
defer_session_refresh, GET /get-session is write-free and returns the exact
camelCase needsRefresh flag; Better Auth's client then uses
POST /get-session to perform the refresh. POST is rejected with 405 unless
that mode is enabled. disableRefresh=true suppresses one request, and
rememberMe: false uses Better Auth's signed dont_remember cookie so the
one-day session never slides and renewed cookies remain non-persistent.
Set config.secondary_storage to an Arc<dyn SecondaryStorage> to make it the
authoritative live-session and verification-value store. Verification records
use verification:<processed identifier> keys, remaining-expiry TTLs, and
atomic getAndDelete consumption. verification.store_in_database adds a
durable mirror; it defaults to false, matching Better Auth. Identifier storage
defaults to Plain; select VerificationIdentifierStorage::Hashed for Better
Auth's SHA-256 base64url profile, provide a Custom async hasher, and use ordered
verification.store_identifier.overrides for purpose-prefix rules. Atomic
verification reservation fails closed when secondary-only storage is selected.
store_session_in_database mirrors sessions to the primary store and
preserve_session_in_database expires instead of deleting that audit row on
revocation. The default rate-limit storage mode also selects configured
secondary storage. Use SessionStorageMode::Stateless only with cookie cache
enabled; pure stateless sessions cannot be individually revoked, so use short
cache lifetimes and version invalidation for incidents. Custom AuthStore
implementations must make session refresh and verification consume/reserve
operations atomic update/delete/insert-only operations; missing or concurrently
deleted records must never be inserted again.
PostgreSQL stores Better Auth's opaque session token directly so listSessions
and revokeSession use the same value. Historical hashed-session layouts are
not a supported compatibility mode.
Lucid-auth automatically emits Better Auth 1.7.2-compatible OpenTelemetry spans through the application's global provider. There is no auth option or instrumentation plugin to install. Applications own provider, exporter, sampler, and propagation configuration; with the default no-op provider, auth behavior and return values are unchanged.
use opentelemetry::global;
use opentelemetry_sdk::trace::SdkTracerProvider;
let provider = SdkTracerProvider::builder().build();
global::set_tracer_provider(provider);
// Construct AuthService normally after application telemetry is configured.The instrumentation scope is better-auth version 1.7.2. It emits only the
pinned low-cardinality endpoint, contributed plugin lifecycle, logical adapter,
and database-hook families. It does not record request bodies, queries,
cookies, tokens, credentials, email addresses, database statements, provider
HTTP calls, business events, or an anonymous telemetry signal. No route,
client, schema, migration, persistence, sampling control, or background span is
added by instrumentation.
Custom AuthPlugin implementations that override on_request,
after_response, or middleware must also return true from the matching
contributes_on_request, contributes_on_response, or
contributes_middleware method. The defaults remain false so inherited
no-op callbacks do not emit lifecycle spans.
The black-box suite installs the exact official Better Auth client versions in
conformance/package-lock.json and runs them against an ephemeral native Rust
server:
npm ci --prefix conformance --ignore-scripts
npm test --prefix conformanceIt currently exercises session, the full username and anonymous lifecycles, admin, all official passkey, user-owned API-key, magic-link, and two-factor client methods. Passkey registration and authentication use complete signatures through an in-process virtual authenticator. The fixture and Node dependencies are excluded from the published crate.
This project is not affiliated with Better Auth.