Better Auth is great. Until you try to test it. Here's how to bypass signature verification and get your test suite running in milliseconds instead of seconds.

Better Auth is great. Until you try to test it.
I spent six hours fighting session validation in Vitest before realizing the library has no first-class testing support. None. You're on your own. And based on the GitHub issues piling up, I'm not alone in discovering this the hard way.
The Problem
Better Auth signs session tokens with HMAC-SHA256 using your BETTER_AUTH_SECRET. The signature gets appended to the token: token.signature. When getSession() runs, it validates that signature before returning user data.
Sounds secure. Is secure. Also makes testing a nightmare.
Your options:
-
Sign tokens correctly - Match Better Auth's internal signing mechanism exactly. Good luck. The implementation details aren't documented, and reverse-engineering cryptographic signatures is exactly as fun as it sounds.
-
Hit the real auth endpoints - Create users via
/sign-up/email, login via/sign-in/email, extract cookies. Works, but adds 100-150ms per test suite due to password hashing. At scale, your test suite becomes a coffee break. -
Mock everything with MSW - Intercept HTTP requests and return fake sessions. Except Better Auth often uses direct function calls, not HTTP. And MSW can strip headers—especially cookies—breaking session retrieval entirely.
-
Wait for official test utilities - There's a feature request asking for exactly this. It's been open since early 2025. The proposed API looks perfect:
auth.test.createUser(),auth.test.login(), cookies that work with both Vitest and Playwright. We're still waiting.
What Actually Works
After trying everything above, here's the solution that got 134 tests passing:
Bypass signature verification entirely.
Mock createAuth at the module level. When getSession() is called, extract the raw token from the cookie and look it up directly in your database. Skip the signature check. Return the real user and session from D1/Postgres/whatever.
// test/setup.ts
vi.mock('../src/lib/auth.server', () => ({
createAuth: vi.fn(() => ({
api: {
async getSession(options: { headers: Headers; asResponse?: boolean }) {
const cookieHeader = options.headers.get('cookie');
if (!cookieHeader) return null;
// Extract token (before the . signature)
const match = cookieHeader.match(/better-auth\.session_token=([^;]+)/);
if (!match) return null;
const decoded = decodeURIComponent(match[1]);
const rawToken = decoded.split('.')[0];
// Look up session directly from DB (bypassing signature verification)
const db = drizzle(env.ezmode);
const [session] = await db
.select()
.from(sessionTable)
.where(eq(sessionTable.token, rawToken))
.limit(1);
if (!session) return null;
const [user] = await db
.select()
.from(userTable)
.where(eq(userTable.id, session.userId))
.limit(1);
return user ? { user, session } : null;
},
},
handler: vi.fn(),
})),
}));Your fixture creates real users and sessions in the database:
export async function createAuthenticatedUser(options = {}) {
const db = drizzle(env.ezmode);
const userId = generateId();
const sessionToken = generateId();
// Create real user in DB
const [user] = await db.insert(userTable).values({
id: userId,
email: options.email || `test-${userId}@example.com`,
role: options.role || 'free',
}).returning();
// Create real session in DB
await db.insert(sessionTable).values({
id: generateId(),
token: sessionToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
// Cookie format matches Better Auth's expectations
const cookie = `better-auth.session_token=${sessionToken}.fakesig; Path=/; HttpOnly`;
return { user, sessionToken, cookie };
}The signature (.fakesig) is garbage. Doesn't matter. The mock ignores it and looks up the session by token directly.
Why This Works
Your auth middleware calls getSession(). The mock intercepts it, extracts the token, queries your test database, and returns the real user with their real role. All your permission checks—requireUser(), requireRole(), requireOwnership()—work against actual data.
The only thing bypassed is signature verification. Everything else is real:
- Real user records in D1/Postgres
- Real session records with real expiration
- Real role-based access control
- Real 401s when sessions don't exist
- Real 403s when roles don't match
The Vitest Gotcha
If you're using Vitest with Better Auth's client, you might hit another bug: createAuthClient returns a Proxy that has a then property. Vitest internally uses Promise.resolve() on fixtures, which treats anything with .then as a thenable. Your tests hang forever.
This was fixed in Better Auth, but if you're on an older version, avoid putting the auth client in fixtures.
What About hasPermission?
If your routes use Better Auth's hasPermission API for organization-level permissions, you'll need to mock that too. The global mock only handles getSession. For permission checks, either:
- Add
hasPermissionto your global mock - Use
vi.spyOnper-test to control the response - Test permission logic separately with unit tests
We went with option 2 for flexibility—different tests need different permission states.
The Better Future
Better Auth maintainers know this is a problem. Issue #5609 proposes exactly what everyone needs:
// The dream API
const { user, session } = await auth.test.createUser({ role: 'creator' });
const cookie = auth.test.createCookie(session);
const headers = auth.test.createHeaders(session);Until that ships, the mock approach works. It's not elegant. It requires understanding Better Auth's internals. But it gets your tests running in milliseconds instead of seconds, with real database state and real permission checks.
Recommendations
-
Use the mock pattern above for integration tests. Real data, fake signatures, fast execution.
-
Don't try to match Better Auth's signing unless you enjoy cryptographic debugging sessions.
-
Skip MSW for auth mocking - Better Auth uses function calls, not just HTTP. MSW won't help.
-
Watch Issue #5609 - When official test utilities ship, migrate to them.
-
Keep permission tests separate - Mock
hasPermissionper-test rather than globally. Different scenarios need different permission states.
Better Auth is genuinely good software. The organization plugin, passkey support, and session management are solid. Testing support just isn't there yet. This workaround bridges the gap until it is.
Resources: