Better Auth is a framework-agnostic authentication framework for TypeScript
Better Auth is a framework-agnostic authentication and authorization framework for TypeScript. It provides a comprehensive set of features out of the box and includes a plugin ecosystem for adding advanced functionalities.
Better Auth features include 2FA and multi-tenant support
Better Auth includes built-in support for features such as two-factor authentication (2FA) and multi-tenant support.
Better Auth installation command
To install Better Auth, run the command: npm i better-auth
Device authorization optional user_id pre-binding
The device authorization plugin now accepts an optional user_id when issuing a device code via /device/code, pre-binding the code to that user. Only the bound user can approve or deny the code, so a publicly visible user code can no longer be claimed by someone else.
Session deletion removes sessions from secondary storage
Deleting a user now also removes their sessions from secondary storage.
JWKS key minting uses transaction-scoped adapter
Minting or reading a JWKS signing key inside an active database transaction now uses the transaction-scoped adapter instead of the root connection. On a single-connection SQLite database with native transactions enabled, this no longer deadlocks, and on Postgres and MySQL the key commits with the surrounding transaction instead of independently.
Refresh token endpoint requires account cookie match
/refresh-token now requires the account cookie's userId, providerId, and (when supplied) accountId to match the resolved session user.
Organization invitations can use database-generated IDs
Organization invitations now let the database generate their id when ID generation is delegated to the database (for example with advanced.database.generateId: "uuid" with a UUID-capable adapter such as Postgres). Previously createInvitation always generated the invitation id in application code. A caller-provided id (for example via beforeCreateInvitation) is still honored.
Admin user creation and update permission guards
The admin plugin now guards protected user fields behind dedicated permissions. /admin/create-user requires user:set-role when a role is supplied and validates requested roles, requires user:ban for ban fields, and no longer lets data override email, name, or role. /admin/update-user requires user:ban for banned/banReason/banExpires and rejects password updates in favor of /admin/set-user-password.
Organization invitation team IDs scoped to organization
Organization invitation team IDs are now scoped to the invited organization. createInvitation validates that every requested teamId belongs to the invitation's organization, and acceptInvitation re-checks each stored team's organization before adding team membership. Previously a team ID from another organization could be stored and applied.
Rate limiting trusts single-value IP headers only
Rate limiting no longer trusts multi-hop X-Forwarded-For chains, preventing a client behind an appending proxy from spoofing the leftmost hop to bypass the per-IP rate limit. Single-value IP headers continue to work. To key the real client behind a proxy chain, set advanced.ipAddress.trustedProxies to reverse-proxy IPs or CIDR ranges (the chain is walked right to left, skipping trusted hops), or point advanced.ipAddress.ipAddressHeaders at a single trusted client-IP header.
Rate limit enforcement before plugin handlers
Rate limiting is now enforced on client requests before plugin request handlers run.
Plugin schema table disableMigration flag honored
Tables flagged with disableMigration: true are now skipped by better-auth generate (Drizzle and Prisma output) and by the runtime migrator, instead of being emitted and created anyway.
Malformed redirect parameters return 400
Malformed redirect parameters now return a 400 instead of a 500.
Session deletion invalidates cached cookie immediately
Deleting a session now immediately stops /update-session and the account token endpoints (/get-access-token, /refresh-token, /account-info) from accepting it when cookie cache is enabled alongside a database or secondary storage. Before, these routes kept serving the deleted session from the cached cookie until the cache expired.
Update-session rejects plugin-managed fields
Passing activeOrganizationId, activeTeamId, or impersonatedBy to /update-session now returns a 400. Change these plugin-managed session fields through their dedicated endpoints instead, such as organization.setActive.
Admin plugin user lookup returns NOT_FOUND
The admin plugin's unbanUser, setRole and adminUpdateUser endpoints now look the user up via findUserById and throw a clean NOT_FOUND (USER_NOT_FOUND) when no row is returned, instead of bubbling up database errors.
List-session endpoint requires fresh session
The list-session endpoint now requires a fresh-age session check.
Multi-session set-active and revoke require matching cookie
The multi-session set-active and revoke endpoints now act only on the session the caller holds a signed cookie for. A request could previously activate or revoke a different session by naming its token in the request body without holding that session's cookie.
Session cookie cache cookie chunking
Session and account cache cookies near the browser's per-cookie size limit are now split into chunks instead of being silently dropped by the browser. A cache too large to fit even when chunked is skipped with a warning, so reads fall back to the database.
Captcha provider verification timeout
Captcha provider verification requests now time out after 10 seconds and fail closed, so a slow or unreachable captcha provider can no longer tie up a request indefinitely.
Delete-account confirmation link prevents double deletion
A delete-account confirmation link can no longer delete the account more than once when its callback is opened concurrently.
Delete-account callback fails on revoked session
Completing account deletion through /delete-user/callback now fails when the session has been revoked server-side instead of proceeding within the cookie-cache window.
Device authorization code prevents concurrent redemption
Polling for a device-authorization token can no longer redeem the same approved device code more than once when several polls arrive together.
Team member limit enforced on all paths
Adding a member to a team that is already at its maximumMembersPerTeam limit is now rejected on every path. A rejected addMember no longer creates the organization member.
Organization invitation acceptance handles missing teams gracefully
Deleting a team no longer breaks its pending invitations. The removed team is dropped from those invitations, which stay valid for their remaining teams or as plain organization-level invitations. Accepting an invitation that still references a missing team fails without consuming the invitation.
Account cookie preserved when switching users
The fresh account cookie issued while switching users in the same browser is now preserved instead of being expired from stale request cookie state.
Session cache returns null for expired sessions
getCookieCache now returns null for an expired session instead of the stale session data. Middleware that calls it to gate access no longer treats an expired signed cookie as a live session.
One-time token prevents concurrent redemption
A one-time token can no longer be redeemed for a session more than once when redeemed concurrently.
Phone-number OTP prevents concurrent redemption
Submitting the same phone-number OTP from several requests at once can no longer sign in more than once or gain extra tries beyond the attempt limit.
Rate limit concurrent request enforcement
Concurrent requests can no longer slip past the configured rate limit. The in-memory rate-limit store no longer grows without bound, and the database backend removes expired entries on its own. A custom rate-limit storage may implement a new optional consume method for strict enforcement.
Organization member role validation on update
Roles are now validated when updating an organization member. Roles are normalized into individual tokens and checked against the configured static and dynamic roles, so unknown or malformed role values are rejected instead of being persisted.
MCP access token expiration validation
Expired MCP access tokens are no longer accepted. A protected MCP resource now rejects a bearer token once it has expired, both on the server and through the remote client. A refresh token is accepted only when the original authorization included the offline_access scope.
MCP remote client CORS 401 challenge exposure
The remote MCP auth client's 401 challenge headers are now exposed to browser clients using CORS.
OpenAPI includes additional fields on email endpoints
OpenAPI now includes user.additionalFields and plugin user schema fields (for example username plugin username / displayUsername) on /sign-up/email and /update-user request bodies.
Organization deletion hook context parameter
The endpoint context is now passed as the second argument to beforeDeleteOrganization and afterDeleteOrganization hooks in the organization plugin, matching the signature shown in the docs and the existing databaseHooks pattern. The Stripe plugin's beforeDeleteOrganization wrapper now forwards the context to user-supplied hooks.
jwtClient preserves type inference with other plugins
jwtClient() no longer collapses createAuthClient type inference when combined with other client plugins such as inferAdditionalFields. Additional user fields (for example on updateUser) are preserved again.
Secondary storage handles invalid session entries
Invalid secondary-storage session entries are now skipped without discarding other valid sessions.
Database rate-limit cleanup completes without handler
Database rate-limit cleanup now completes when no background task handler is configured.
Model name alias matching improved in adapter queries
Exact schema-key matches are now preferred over modelName aliases in getDefaultModelName, so remapping a built-in table onto another table's schema key (for example user.modelName = "account") does not reroute internal adapter queries to the wrong table.
Drizzle Kysely migration field uniqueness
Drizzle and Kysely migration generation now handles fields that are both unique: true and index: true correctly.
Organization member listing limit consistency
organization.listMembers now applies the same membership limit to the users query, fixing failures with more than approximately 100 members.
Foreign key collision detection in schema remapping
Silent foreign-key and adapter-join misrouting is now fixed when a user remaps a built-in model name to a string that collides with another schema key.
Verification callback request cloning error handling
Verification callbacks no longer fail auth requests when cloning the request throws.
SQLite BIGINT type migration diff recognition
SQLite BIGINT is now recognized as a valid number type in migration diffs, so database-backed rate limiter columns like lastRequest no longer report spurious pending changes.
Auth query revalidation signal listeners restored after remount
Auth query revalidation and signal listeners are now restored after client remount.
Last-login-method beforeStoreCookie GDPR compliance option
The last-login-method plugin now includes a beforeStoreCookie option for GDPR compliance, allowing custom handling of login method storage.
Session refresh no longer emits Max-Age above browser ceiling
Session refresh no longer emits a cookie Max-Age above the browser's 400-day ceiling when using a database without fractional-second precision.
Refresh token rotation now works on Prisma
Refresh-token rotation and token revocation, two-factor backup-code regeneration, device-code claiming, and organization invitation acceptance now work on Prisma. Concurrent or repeat requests in these flows could previously return an error.
MongoDB guarded value updates compatibility
On MongoDB servers older than 5.0, guarded value updates (rate-limit window resets, API-key refills) no longer fail with an empty-update error.
Last-login-method cross-subdomain cookie clearing
The last-login-method plugin now includes domain when clearing cross-subdomain cookies.
Admin permission changes take effect immediately
Admin permission changes and bans now take effect immediately for admin APIs, even when session cookie cache is enabled. Sensitive session checks also continue to work in stateless apps where signed cookies are the session record.
Device authorization ZodError fix without schema
deviceAuthorization() no longer throws a ZodError at construction when called without a schema option.
Solid client $fetch and $store atoms exposed
The Solid client now exposes the real $fetch instance and $store atoms instead of resolving them as dynamic API routes.
API error property inheritance in TypeScript
Inherited APIError properties are now declared to fix TypeScript inference errors.
Team member limit concurrent acceptance fix
When a team had a single open slot, accepting an invitation into it was wrongly rejected as over the member limit. Two invitations accepted into a nearly-full team at the same time could also push it past its limit. Both are fixed.
BaseURL fallback for password-reset and verification links
When baseURL is not configured, password-reset and verification links now use the current request's host rather than the host of the first request the server handled.
Captcha provider optional hostname and action validation
Google reCAPTCHA and Cloudflare Turnstile now accept optional expectedAction and allowedHostnames to reject tokens minted for a different action or hostname.
IPv6 reserved ranges in server-side fetches
Server-side fetches now reject additional reserved IPv6 ranges.
Root-mounted deployment path validation
In root-mounted deployments, requests whose path does not start with the configured basePath now return 404 instead of resolving to an endpoint.