From f5976dd4bbfacd11bf868c8ae6e09c525e16a5bf Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:15:14 -0800 Subject: [PATCH] chore: support stateless without any configuration (#5916) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- demo/stateless/src/lib/auth.ts | 16 +- .../docs/concepts/session-management.mdx | 78 ++-- docs/content/docs/errors/index.mdx | 3 +- docs/content/docs/errors/signup_disabled.mdx | 32 ++ .../__snapshots__/create-context.test.ts.snap | 377 ++++++++++++++++++ .../{base.test.ts => create-context.test.ts} | 44 +- .../context/{base.ts => create-context.ts} | 18 + .../better-auth/src/context/init-minimal.ts | 2 +- packages/better-auth/src/context/init.ts | 2 +- packages/better-auth/src/db/adapter-base.ts | 3 - 10 files changed, 524 insertions(+), 51 deletions(-) create mode 100644 docs/content/docs/errors/signup_disabled.mdx create mode 100644 packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap rename packages/better-auth/src/context/{base.test.ts => create-context.test.ts} (96%) rename packages/better-auth/src/context/{base.ts => create-context.ts} (94%) diff --git a/demo/stateless/src/lib/auth.ts b/demo/stateless/src/lib/auth.ts index 407d0bad3d..a2c5413b05 100644 --- a/demo/stateless/src/lib/auth.ts +++ b/demo/stateless/src/lib/auth.ts @@ -1,4 +1,4 @@ -import { betterAuth } from "better-auth"; +import { betterAuth } from "better-auth/minimal"; const baseURL: string | undefined = process.env.VERCEL === "1" @@ -11,24 +11,10 @@ const baseURL: string | undefined = export const auth = betterAuth({ baseURL, - secret: process.env.BETTER_AUTH_SECRET, - socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID as string, clientSecret: process.env.GITHUB_CLIENT_SECRET as string, }, }, - - session: { - cookieCache: { - enabled: true, - }, - }, - - advanced: { - oauthConfig: { - storeStateStrategy: "cookie", - }, - }, }); diff --git a/docs/content/docs/concepts/session-management.mdx b/docs/content/docs/concepts/session-management.mdx index 0e75401e70..4b2e6b9f4e 100644 --- a/docs/content/docs/concepts/session-management.mdx +++ b/docs/content/docs/concepts/session-management.mdx @@ -233,31 +233,52 @@ await auth.api.getSession({ ## Stateless Session Management -Better Auth supports fully stateless session management, allowing you to run authentication without any database at all. This is ideal for serverless environments, edge computing, or scenarios where you want to minimize database queries. - -In stateless mode, all session data is stored in signed/encrypted cookies. The server never queries a database to validate sessions—it simply verifies the cookie signature and checks expiration. This approach is similar to JWT-based authentication but with Better Auth's full feature set. +Better Auth supports stateless session management without any database. This means that the session data is stored in a signed/encrypted cookie and the server never queries a database to validate sessions - it simply verifies the cookie signature and checks expiration. ### Basic Stateless Setup -To enable stateless sessions, configure cookie cache with `refreshCache` enabled: +If you don't pass a database configuration, Better Auth will automatically enable stateless mode. ```ts title="auth.ts" import { betterAuth } from "better-auth" export const auth = betterAuth({ - // No database configuration needed for stateless mode - session: { - expiresIn: 60 * 60 * 24 * 7, // 7 days - cookieCache: { - enabled: true, - maxAge: 30 * 24 * 60 * 60, // 30 days cache duration - strategy: "jwe", // Use encrypted tokens for better security - refreshCache: true // Enable stateless refresh - } - } + // No database configuration + socialProviders: { + google: { + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }, + }, }); ``` +To manually enable stateless mode, you need to configure `cookieCache` and `oauthConfig` with the following options: + +```ts title="auth.ts" +import { betterAuth } from "better-auth" + +export const auth = betterAuth({ + session: { + cookieCache: { + enabled: true, + maxAge: 7 * 24 * 60 * 60, // 7 days cache duration + strategy: "jwe", // can be "jwt" or "compact" + refreshCache: true, // Enable stateless refresh + }, + }, + advanced: { + oauthConfig: { + storeStateStrategy: "cookie", + }, + }, +}); +``` + + +If you don't provide a database, by default we provide the above configuration for you. + + ### Understanding `refreshCache` The `refreshCache` option controls automatic cookie refresh **before expiry** without querying any database: @@ -280,16 +301,23 @@ export const auth = betterAuth({ }); ``` -### Stateless vs Database-Backed Sessions +### Versioning Stateless Sessions -| Feature | Stateless (refreshCache enabled) | Database-Backed (default) | -|---------|----------------------------------|---------------------------| -| Database queries | None (fully stateless) | Queries DB after cache expires | -| Session revocation | Not immediate (until cookie expires) | Immediate | -| Scalability | Excellent (no DB bottleneck) | Good (with proper caching) | -| Security | Good (encrypted cookies) | Better (server-side validation) | -| Use case | High-traffic, edge computing | Traditional apps, need revocation | +One of the biggest drawbacks of stateless sessions is that you can't invalidate session easily. To solve this with better auth, if you would like to invalidate all sessions, you can change the version of the cookie cache and re-deploy your application. +```ts title="auth.ts" +export const auth = betterAuth({ + session: { + cookieCache: { + version: "2", // [!code highlight] Change the version to invalidate all sessions + } + } +}); +``` + + +This will invalidate all sessions that don't match the new version. + ### Stateless with Secondary Storage @@ -308,10 +336,8 @@ export const auth = betterAuth({ }, session: { cookieCache: { - enabled: true, - maxAge: 60 * 60, // 1 hour - strategy: "jwe", - refreshCache: false + maxAge: 5 * 60, // [!code highlight] 5 minutes (short-lived cookie) + refreshCache: false // [!code highlight] Disable stateless refresh } } }); diff --git a/docs/content/docs/errors/index.mdx b/docs/content/docs/errors/index.mdx index c8edda9795..d4c25367bd 100644 --- a/docs/content/docs/errors/index.mdx +++ b/docs/content/docs/errors/index.mdx @@ -17,4 +17,5 @@ This section contains all the errors that cause you to be redirected to the `/ap - [OAuth provider not found](/docs/errors/oauth_provider_not_found) - [Unable to link account](/docs/errors/unable_to_link_account) - [Unable to get user info](/docs/errors/unable_to_get_user_info) -- [State mismatch](/docs/errors/state_mismatch) \ No newline at end of file +- [State mismatch](/docs/errors/state_mismatch) +- [Signup disabled](/docs/errors/signup_disabled) \ No newline at end of file diff --git a/docs/content/docs/errors/signup_disabled.mdx b/docs/content/docs/errors/signup_disabled.mdx new file mode 100644 index 0000000000..8ddb644ec5 --- /dev/null +++ b/docs/content/docs/errors/signup_disabled.mdx @@ -0,0 +1,32 @@ +--- +title: Signup disabled +description: Signup disabled error +--- + +This error occurs when you disable sign up in your oauth provider config and a user tries to sign up with that provider. + +## How to fix + +If you're using the `disableSignUp` option with stateless mode, you will see this error. Please consider using database hooks instead to handle this case. + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; +import { APIError } from "better-auth/api"; + +export const auth = betterAuth({ + databaseHooks: { + user: { + create: { + before: async (user, ctx) => { + const isAllowedToSignUp = await isAllowedToSignUp(user, ctx); // [!code highlight] // check if the user is allowed to sign up + if (!isAllowedToSignUp) { + throw new APIError("BAD_REQUEST", { + message: "Signup is disabled", + }); + }, + }, + }, + }, + } +}); +``` \ No newline at end of file diff --git a/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap new file mode 100644 index 0000000000..a8f617c748 --- /dev/null +++ b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap @@ -0,0 +1,377 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`base context creation > should match config 1`] = ` +{ + "adapter": { + "count": [Function], + "create": [Function], + "createSchema": undefined, + "delete": [Function], + "deleteMany": [Function], + "findMany": [Function], + "findOne": [Function], + "id": "memory", + "options": { + "adapterConfig": { + "adapterId": "memory", + "adapterName": "Memory Adapter", + "customTransformInput": [Function], + "debugLogs": false, + "disableTransformInput": false, + "disableTransformOutput": false, + "supportsBooleans": true, + "supportsDates": true, + "supportsJSON": false, + "supportsNumericIds": true, + "supportsUUIDs": false, + "transaction": [Function], + "usePlural": false, + }, + }, + "transaction": [Function], + "update": [Function], + "updateMany": [Function], + }, + "appName": "Better Auth", + "authCookies": { + "dontRememberToken": { + "name": "better-auth.dont_remember", + "options": { + "httpOnly": true, + "path": "/", + "sameSite": "lax", + "secure": false, + }, + }, + "sessionData": { + "name": "better-auth.session_data", + "options": { + "httpOnly": true, + "maxAge": 300, + "path": "/", + "sameSite": "lax", + "secure": false, + }, + }, + "sessionToken": { + "name": "better-auth.session_token", + "options": { + "httpOnly": true, + "maxAge": 604800, + "path": "/", + "sameSite": "lax", + "secure": false, + }, + }, + }, + "baseURL": "http://localhost:3000/api/auth", + "createAuthCookie": [Function], + "generateId": [Function], + "internalAdapter": { + "countTotalUsers": [Function], + "createAccount": [Function], + "createOAuthUser": [Function], + "createSession": [Function], + "createUser": [Function], + "createVerificationValue": [Function], + "deleteAccount": [Function], + "deleteAccounts": [Function], + "deleteSession": [Function], + "deleteSessions": [Function], + "deleteUser": [Function], + "deleteVerificationByIdentifier": [Function], + "deleteVerificationValue": [Function], + "findAccount": [Function], + "findAccountByProviderId": [Function], + "findAccountByUserId": [Function], + "findAccounts": [Function], + "findOAuthUser": [Function], + "findSession": [Function], + "findSessions": [Function], + "findUserByEmail": [Function], + "findUserById": [Function], + "findVerificationValue": [Function], + "linkAccount": [Function], + "listSessions": [Function], + "listUsers": [Function], + "updateAccount": [Function], + "updatePassword": [Function], + "updateSession": [Function], + "updateUser": [Function], + "updateUserByEmail": [Function], + "updateVerificationValue": [Function], + }, + "logger": { + "debug": [Function], + "error": [Function], + "info": [Function], + "level": "error", + "success": [Function], + "warn": [Function], + }, + "newSession": null, + "oauthConfig": { + "skipStateCookieCheck": false, + "storeStateStrategy": "cookie", + }, + "options": { + "advanced": { + "oauthConfig": { + "storeStateStrategy": "cookie", + }, + }, + "basePath": "/api/auth", + "baseURL": "http://localhost:3000", + "plugins": [], + "secret": "better-auth-secret-123456789", + "session": { + "cookieCache": { + "enabled": true, + "refreshCache": true, + "strategy": "jwe", + }, + }, + }, + "password": { + "checkPassword": [Function], + "config": { + "maxPasswordLength": 128, + "minPasswordLength": 8, + }, + "hash": [Function], + "verify": [Function], + }, + "publishTelemetry": [Function], + "rateLimit": { + "enabled": false, + "max": 100, + "storage": "memory", + "window": 10, + }, + "runMigrations": [Function], + "secondaryStorage": undefined, + "secret": "better-auth-secret-123456789", + "session": null, + "sessionConfig": { + "cookieRefreshCache": { + "enabled": true, + "updateAge": 60, + }, + "expiresIn": 604800, + "freshAge": 86400, + "updateAge": 86400, + }, + "setNewSession": [Function], + "skipCSRFCheck": false, + "skipOriginCheck": true, + "socialProviders": [], + "tables": { + "account": { + "fields": { + "accessToken": { + "fieldName": "accessToken", + "required": false, + "type": "string", + }, + "accessTokenExpiresAt": { + "fieldName": "accessTokenExpiresAt", + "required": false, + "type": "date", + }, + "accountId": { + "fieldName": "accountId", + "required": true, + "type": "string", + }, + "createdAt": { + "defaultValue": [Function], + "fieldName": "createdAt", + "required": true, + "type": "date", + }, + "idToken": { + "fieldName": "idToken", + "required": false, + "type": "string", + }, + "password": { + "fieldName": "password", + "required": false, + "type": "string", + }, + "providerId": { + "fieldName": "providerId", + "required": true, + "type": "string", + }, + "refreshToken": { + "fieldName": "refreshToken", + "required": false, + "type": "string", + }, + "refreshTokenExpiresAt": { + "fieldName": "refreshTokenExpiresAt", + "required": false, + "type": "date", + }, + "scope": { + "fieldName": "scope", + "required": false, + "type": "string", + }, + "updatedAt": { + "fieldName": "updatedAt", + "onUpdate": [Function], + "required": true, + "type": "date", + }, + "userId": { + "fieldName": "userId", + "index": true, + "references": { + "field": "id", + "model": "user", + "onDelete": "cascade", + }, + "required": true, + "type": "string", + }, + }, + "modelName": "account", + "order": 3, + }, + "session": { + "fields": { + "createdAt": { + "defaultValue": [Function], + "fieldName": "createdAt", + "required": true, + "type": "date", + }, + "expiresAt": { + "fieldName": "expiresAt", + "required": true, + "type": "date", + }, + "ipAddress": { + "fieldName": "ipAddress", + "required": false, + "type": "string", + }, + "token": { + "fieldName": "token", + "required": true, + "type": "string", + "unique": true, + }, + "updatedAt": { + "fieldName": "updatedAt", + "onUpdate": [Function], + "required": true, + "type": "date", + }, + "userAgent": { + "fieldName": "userAgent", + "required": false, + "type": "string", + }, + "userId": { + "fieldName": "userId", + "index": true, + "references": { + "field": "id", + "model": "user", + "onDelete": "cascade", + }, + "required": true, + "type": "string", + }, + }, + "modelName": "session", + "order": 2, + }, + "user": { + "fields": { + "createdAt": { + "defaultValue": [Function], + "fieldName": "createdAt", + "required": true, + "type": "date", + }, + "email": { + "fieldName": "email", + "required": true, + "sortable": true, + "type": "string", + "unique": true, + }, + "emailVerified": { + "defaultValue": false, + "fieldName": "emailVerified", + "input": false, + "required": true, + "type": "boolean", + }, + "image": { + "fieldName": "image", + "required": false, + "type": "string", + }, + "name": { + "fieldName": "name", + "required": true, + "sortable": true, + "type": "string", + }, + "updatedAt": { + "defaultValue": [Function], + "fieldName": "updatedAt", + "onUpdate": [Function], + "required": true, + "type": "date", + }, + }, + "modelName": "user", + "order": 1, + }, + "verification": { + "fields": { + "createdAt": { + "defaultValue": [Function], + "fieldName": "createdAt", + "required": true, + "type": "date", + }, + "expiresAt": { + "fieldName": "expiresAt", + "required": true, + "type": "date", + }, + "identifier": { + "fieldName": "identifier", + "index": true, + "required": true, + "type": "string", + }, + "updatedAt": { + "defaultValue": [Function], + "fieldName": "updatedAt", + "onUpdate": [Function], + "required": true, + "type": "date", + }, + "value": { + "fieldName": "value", + "required": true, + "type": "string", + }, + }, + "modelName": "verification", + "order": 4, + }, + }, + "trustedOrigins": [ + "http://localhost:3000", + ], +} +`; diff --git a/packages/better-auth/src/context/base.test.ts b/packages/better-auth/src/context/create-context.test.ts similarity index 96% rename from packages/better-auth/src/context/base.test.ts rename to packages/better-auth/src/context/create-context.test.ts index 2c54ad0c65..9bd02bf139 100644 --- a/packages/better-auth/src/context/base.test.ts +++ b/packages/better-auth/src/context/create-context.test.ts @@ -1,9 +1,10 @@ +import Database from "better-sqlite3"; import { describe, expect, it, vi } from "vitest"; import { createAuthEndpoint } from "../api"; import { getAdapter } from "../db"; import { getTestInstance } from "../test-utils/test-instance"; import type { BetterAuthOptions } from "../types"; -import { createAuthContext } from "./base"; +import { createAuthContext } from "./create-context"; describe("base context creation", () => { const initBase = async (options: Partial = {}) => { @@ -274,10 +275,17 @@ describe("base context creation", () => { }); it("should return false for cookieRefreshCache when undefined", async () => { - const res = await initBase({}); + const res = await initBase({ + database: new Database(":memory:"), + }); expect(res.sessionConfig.cookieRefreshCache).toBe(false); }); + it("should set a value for cookieRefreshCache when database isn't provided", async () => { + const res = await initBase({}); + expect(res.sessionConfig.cookieRefreshCache).not.toBe(false); + }); + it("should return false for cookieRefreshCache when explicitly false", async () => { const res = await initBase({ session: { @@ -550,9 +558,9 @@ describe("base context creation", () => { }); describe("oauth configuration", () => { - it("should use database as default storeStateStrategy", async () => { + it("should use cookie strategy as default storeStateStrategy if database is not provided", async () => { const res = await initBase({}); - expect(res.oauthConfig.storeStateStrategy).toBe("database"); + expect(res.oauthConfig.storeStateStrategy).toBe("cookie"); }); it("should allow cookie storeStateStrategy", async () => { @@ -1425,4 +1433,32 @@ describe("base context creation", () => { expect(ctx.baseURL).toBe("http://localhost:3000/api/v1/auth-service"); }); }); + + describe("stateless mode", () => { + it("should enable stateless mode by default", async () => { + const ctx = await initBase({}); + expect(ctx.options.session?.cookieCache?.enabled).toBe(true); + expect(ctx.options.session?.cookieCache?.strategy).toBe("jwe"); + expect(ctx.options.session?.cookieCache?.refreshCache).toBe(true); + expect(ctx.oauthConfig.storeStateStrategy).toBe("cookie"); + expect(ctx.options.database).toBeUndefined(); + }); + + it("should allow overriding stateless mode", async () => { + const ctx = await initBase({ + session: { + cookieCache: { + enabled: false, + }, + }, + advanced: { + oauthConfig: { + storeStateStrategy: "database", + }, + }, + }); + expect(ctx.options.session?.cookieCache?.enabled).toBe(false); + expect(ctx.oauthConfig.storeStateStrategy).toBe("database"); + }); + }); }); diff --git a/packages/better-auth/src/context/base.ts b/packages/better-auth/src/context/create-context.ts similarity index 94% rename from packages/better-auth/src/context/base.ts rename to packages/better-auth/src/context/create-context.ts index bfde17b314..f29732c8a5 100644 --- a/packages/better-auth/src/context/base.ts +++ b/packages/better-auth/src/context/create-context.ts @@ -8,6 +8,7 @@ import { socialProviders, } from "@better-auth/core/social-providers"; import { createTelemetry } from "@better-auth/telemetry"; +import defu from "defu"; import type { Entries } from "type-fest"; import { checkEndpointConflicts } from "../api"; import { createCookieGetter, getCookies } from "../cookies"; @@ -30,6 +31,23 @@ export async function createAuthContext( options: BetterAuthOptions, getDatabaseType: (database: BetterAuthOptions["database"]) => string, ): Promise { + //set default options for stateless mode + if (!options.database) { + options = defu(options, { + session: { + cookieCache: { + enabled: true, + strategy: "jwe" as const, + refreshCache: true, + }, + }, + advanced: { + oauthConfig: { + storeStateStrategy: "cookie" as const, + }, + }, + }); + } const plugins = options.plugins || []; const internalPlugins = getInternalPlugins(options); const logger = createLogger(options.logger); diff --git a/packages/better-auth/src/context/init-minimal.ts b/packages/better-auth/src/context/init-minimal.ts index 606080b55c..ccfbca5ebe 100644 --- a/packages/better-auth/src/context/init-minimal.ts +++ b/packages/better-auth/src/context/init-minimal.ts @@ -1,7 +1,7 @@ import type { BetterAuthOptions } from "@better-auth/core"; import { BetterAuthError } from "@better-auth/core/error"; import { getBaseAdapter } from "../db"; -import { createAuthContext } from "./base"; +import { createAuthContext } from "./create-context"; export const initMinimal = async (options: BetterAuthOptions) => { const adapter = await getBaseAdapter(options, async () => { diff --git a/packages/better-auth/src/context/init.ts b/packages/better-auth/src/context/init.ts index 486bc81988..bc34e573f3 100644 --- a/packages/better-auth/src/context/init.ts +++ b/packages/better-auth/src/context/init.ts @@ -3,7 +3,7 @@ import { getKyselyDatabaseType } from "../adapters/kysely-adapter/dialect"; import { getAdapter } from "../db/adapter-kysely"; import { getMigrations } from "../db/get-migration"; import type { BetterAuthOptions } from "../types"; -import { createAuthContext } from "./base"; +import { createAuthContext } from "./create-context"; export const init = async (options: BetterAuthOptions) => { const adapter = await getAdapter(options); diff --git a/packages/better-auth/src/db/adapter-base.ts b/packages/better-auth/src/db/adapter-base.ts index 426e90444e..682ccf0167 100644 --- a/packages/better-auth/src/db/adapter-base.ts +++ b/packages/better-auth/src/db/adapter-base.ts @@ -18,9 +18,6 @@ export async function getBaseAdapter( acc[key] = []; return acc; }, {}); - logger.warn( - "No database configuration provided. Using memory adapter in development", - ); const { memoryAdapter } = await import("../adapters/memory-adapter"); adapter = memoryAdapter(memoryDB)(options); } else if (typeof options.database === "function") {