chore: support stateless without any configuration (#5916)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Bereket Engida
2025-11-11 16:15:14 -08:00
committed by GitHub
co-authored by Copilot
parent e7b67d2ec1
commit f5976dd4bb
10 changed files with 524 additions and 51 deletions
+1 -15
View File
@@ -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",
},
},
});
@@ -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",
},
},
});
```
<Callout type="info">
If you don't provide a database, by default we provide the above configuration for you.
</Callout>
### 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
}
}
});
```
<Callout type="warning">
This will invalidate all sessions that don't match the new version.
</Callout>
### 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
}
}
});
+2 -1
View File
@@ -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)
- [State mismatch](/docs/errors/state_mismatch)
- [Signup disabled](/docs/errors/signup_disabled)
@@ -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",
});
},
},
},
},
}
});
```
@@ -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",
],
}
`;
@@ -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<BetterAuthOptions> = {}) => {
@@ -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");
});
});
});
@@ -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<AuthContext> {
//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);
@@ -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 () => {
+1 -1
View File
@@ -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);
@@ -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") {