Better Auth
Authentication for Vela apps via better-auth — email/password, social OAuth, magic link, passkeys, 2FA, and organizations, with full DI integration.
@velajs/better-auth wraps better-auth as a first-class Vela module. You write a normal betterAuth({...}) config; the module wires AuthGuard, @CurrentUser() / @CurrentSession() decorators, and an auto-mounted /api/auth/* catch-all controller. The whole better-auth feature surface — email/password, social OAuth, magic link, passkey, 2FA, organizations, admin — is available through the same DI graph.
Installation
npm install @velajs/better-auth better-authQuick start
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from '@better-auth/drizzle-adapter';
import {
Controller, Get, Module, UseGuards, VelaFactory,
} from '@velajs/vela';
import {
AuthGuard, BetterAuthModule, CurrentUser, Public,
type User,
} from '@velajs/better-auth';
const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
emailAndPassword: { enabled: true },
socialProviders: { github: { clientId, clientSecret } },
});
@Controller('/me')
@UseGuards(AuthGuard)
class MeController {
@Get() me(@CurrentUser() user: User) {
return { id: user.id, email: user.email };
}
@Get('/health') @Public(true)
health() { return { ok: true }; }
}
@Module({
imports: [BetterAuthModule.forRoot({ auth, isGlobal: true })],
controllers: [MeController],
})
class AppModule {}
const app = await VelaFactory.create(AppModule);
export default app; // .fetch — Cloudflare Workers, Bun, Deno, NodePOST /api/auth/sign-up/email, POST /api/auth/sign-in/email, GET /api/auth/sign-in/social/github, and the rest of better-auth's route surface are auto-mounted at /api/auth/*. Plugins you add to betterAuth({ plugins: [...] }) add their routes to the same prefix.
Two registration modes
BetterAuthModule.forRoot({ auth })— synchronous. Use when the inputs tobetterAuth({...})are available at startup (Node apps with a static DB connection, in-memory adapters, etc.).BetterAuthModule.forRootAsync({ inject, useFactory })— deferred. TheuseFactoryruns lazily on first auth use — typically the first request — so values only available at request time (Cloudflare D1/KV/R2 bindings, Deno Deploy env, ...) are safe to read inside the factory. Required for Cloudflare bindings; see Cloudflare D1.
Decorators
| Decorator | Purpose |
|---|---|
@CurrentUser() | Lazy parameter — better-auth User populated by AuthGuard |
@CurrentSession() | Lazy parameter — better-auth Session |
@Public(true) | Class or method — bypass AuthGuard entirely |
@OptionalAuth(true) | Populate user if present, never throw 401 |
@Roles(['admin']) | Method — RolesGuard compares against user.role |
@CurrentUser() returns a lazy proxy (it's always object-truthy because it's a proxy). When auth is optional, probe a property to materialize:
handle(@CurrentUser() user: User | undefined) {
return { hasUser: user?.id != null };
}Guards
AuthGuard— singleton. ReadsAuthorizationheader / cookies viaauth.api.getSession, populates the per-request bag with the user and session. Honors@Public()and@OptionalAuth(). Always lets requests underbasePathpass through (so the catch-all controller can run unauthenticated).RolesGuard— singleton. Reads@Roles([...])metadata, compares againstuser.role. Use with@UseGuards(AuthGuard, RolesGuard)— order matters.
Register AuthGuard globally via isGlobal: true in forRoot/forRootAsync. Routes are then deny-by-default; mark public routes with @Public(true).
Service-level access
Inject BetterAuthService to call auth.api.* from any vela service or controller — the canonical pattern for admin operations, session management, etc.:
import { Inject, Injectable } from '@velajs/vela';
import { BetterAuthService } from '@velajs/better-auth';
@Injectable()
class AdminUserService {
constructor(@Inject(BetterAuthService) private readonly auth: BetterAuthService) {}
listSessions(userId: string) {
return this.auth.api.listUserSessions({ userId });
}
revoke(sessionToken: string) {
return this.auth.api.revokeSession({ sessionToken });
}
}The service exposes .auth (the full betterAuth instance), .api, and .handler as getters. Under forRootAsync, the underlying betterAuth({...}) is constructed lazily on first .auth / .api / .handler access — that's how Cloudflare bindings become safe to read inside the user factory.
Testing
Use @velajs/testing to override BetterAuthService for unit tests:
import { Test } from '@velajs/testing';
import { BetterAuthService } from '@velajs/better-auth';
const moduleRef = await Test.createTestingModule({
imports: [BetterAuthModule.forRoot({ auth: realAuth })],
controllers: [MeController],
})
.overrideProvider(BetterAuthService)
.useValue({
auth: mockAuth,
get api() { return mockAuth.api; },
get handler() { return mockAuth.handler; },
})
.compile();Requires @velajs/testing >= 0.2.1 — earlier versions don't set up REQUEST_CONTEXT correctly, so guards depending on it crash. See @velajs/testing 0.2.1 release notes.
Edge runtime
@velajs/better-auth itself is node:-clean (no node:* imports, no Buffer, no process.*). Edge-safety of your runtime depends on the database adapter:
| Adapter | Edge-safe |
|---|---|
drizzleAdapter + drizzle-orm/d1 | ✅ |
drizzleAdapter + @neondatabase/serverless | ✅ |
kyselyAdapter + kysely-d1 | ✅ |
prismaAdapter + @prisma/adapter-d1 | ✅ |
drizzleAdapter + better-sqlite3 | ❌ |
prismaAdapter (default, no edge client) | ❌ |
When deploying to Cloudflare Workers, smoke-test the bundle: grep -r "node:" dist/ should return nothing.
Workerd gotcha — direct adapter imports
Adapter modules under better-auth/adapters/* are one-line export * from '@better-auth/<name>-adapter' re-exports. Esbuild (Wrangler's bundler) wraps export * chains in an async init shim, leaving the named import undefined at top-level evaluation. Symptom:
TypeError: memoryAdapter is not a functionFix: import directly from the underlying package.
// Bad (under wrangler):
import { memoryAdapter } from 'better-auth/adapters/memory';
// Good:
import { memoryAdapter } from '@better-auth/memory-adapter';Same applies to @better-auth/drizzle-adapter, @better-auth/kysely-adapter, etc.