VelaVela
Better Auth

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-auth

Quick 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, Node

POST /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 to betterAuth({...}) are available at startup (Node apps with a static DB connection, in-memory adapters, etc.).
  • BetterAuthModule.forRootAsync({ inject, useFactory }) — deferred. The useFactory runs 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

DecoratorPurpose
@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. Reads Authorization header / cookies via auth.api.getSession, populates the per-request bag with the user and session. Honors @Public() and @OptionalAuth(). Always lets requests under basePath pass through (so the catch-all controller can run unauthenticated).
  • RolesGuard — singleton. Reads @Roles([...]) metadata, compares against user.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:

AdapterEdge-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 function

Fix: 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.

On this page