Better Auth with NestJS: sessions, OAuth and organizations
Better Auth is a TypeScript auth library that runs inside your own server, with your own database: email and password, magic links, OAuth, sessions, and organizations with members and roles. It is built for frameworks that hand it a request, and NestJS is not one of its first-class integrations. It works well there, with a few things to get right.
This guide covers the setup, a guard that protects every route unless you say otherwise, and organizations used as workspaces, with each one’s data kept apart.
1. Make auth a Nest provider
Create the Better Auth instance in a factory, so it can use your other services: the database, the mailer, billing.
export const AUTH = Symbol('AUTH');
@Global()@Module({ providers: [{ provide: AUTH, inject: [Database, MailService], useFactory: (db: Database, mail: MailService) => createAuth(db, mail), }], exports: [AUTH],})export class AuthModule {}export function createAuth(db: Database, mail: MailService) { return betterAuth({ baseURL: process.env.WEB_URL, basePath: '/api/auth', trustedOrigins: [process.env.WEB_URL!], database: drizzleAdapter(db.client, { provider: 'pg', schema, usePlural: true, }), emailAndPassword: { enabled: true, requireEmailVerification: true, revokeSessionsOnPasswordReset: true, sendResetPassword: ({ user, url }) => mail.queue(user.email, 'reset', { url }), }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, }, plugins: [ magicLink({ sendMagicLink: ({ email, url }) => mail.queue(email, 'magic-link', { url }) }), organization({ creatorRole: 'owner' }), ], });}Send its emails through your job queue rather than inline, so a slow mail server does not hold up a sign-up, and an outage does not lose the email.
Better Auth has adapters for Drizzle, Prisma and Kysely. With TypeORM, which it has no adapter for, use the Kysely adapter on TypeORM’s connection pool.
2. Mount it before Nest parses the body
Better Auth reads the raw request itself. If Nest’s JSON body parser gets there first, the stream is already consumed and auth requests hang or fail. Mount its handler as plain Express middleware, before the app starts listening (which is when Nest adds its parsers):
import { toNodeHandler } from 'better-auth/node';
const app = await NestFactory.create(AppModule);app.setGlobalPrefix('api');app.enableCors({ origin: process.env.WEB_URL, credentials: true,});
const handler = toNodeHandler(app.get(AUTH));app.use((req: Request, res: Response, next: NextFunction) => { if (!req.path.startsWith('/api/auth/')) return next(); handler(req, res).catch(next);});
await app.listen(3000);Two details:
- Spell out
/api/auth.setGlobalPrefixapplies to controllers, not to middleware added withapp.use. - CORS comes first, so the browser’s preflight requests for auth routes are answered too.
Do not write Nest controllers that repeat Better Auth’s endpoints. Its client calls them directly.
3. Protect every route by default
A global guard that requires a session means a route you forget to protect is protected anyway. Opting out is explicit:
export const IS_PUBLIC = 'isPublic';export const Public = () => SetMetadata(IS_PUBLIC, true);
@Injectable()export class AuthGuard implements CanActivate { constructor( @Inject(AUTH) private readonly auth: Auth, private readonly reflector: Reflector, ) {}
async canActivate(context: ExecutionContext) { const targets = [context.getHandler(), context.getClass()]; if (this.reflector.getAllAndOverride(IS_PUBLIC, targets)) { return true; } const req = context.switchToHttp().getRequest(); const session = await this.auth.api.getSession({ headers: fromNodeHeaders(req.headers), }); if (!session) throw new UnauthorizedException(); req.auth = session; return true; }}Register it with { provide: APP_GUARD, useClass: AuthGuard }, and read the user with a small parameter decorator:
export const CurrentUser = createParamDecorator( (_: unknown, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().auth.user,);4. Organizations as workspaces
The organization plugin gives you organizations, members, roles and invitations. To keep each organization’s data apart, put its id in the URL and check membership in a guard on those routes:
@Injectable()export class WorkspaceGuard implements CanActivate { async canActivate(context: ExecutionContext) { const req = context.switchToHttp().getRequest(); const id = z.uuid().safeParse(req.params.workspaceId); const userId = req.auth.user.id; const workspace = id.success ? await this.workspaces.findForMember(id.data, userId) : undefined; // The same answer for "not yours" and "does not exist" if (!workspace) throw new NotFoundException(); req.workspace = workspace; return true; }}@UseGuards(WorkspaceGuard)@Controller('workspaces/:workspaceId/projects')export class ProjectsController { @Get() list(@CurrentWorkspace() workspace: Workspace) { return this.projects.list(workspace.id); }}- Answer 404, not 403, to someone who is not a member, so ids cannot be probed to find out which exist.
- The guard is one layer, not the only one. Every query filters by the workspace id as well, including updates and deletes by id. And the id comes from
req.workspace, never from the request body. - Roles are stored as text, comma-separated when a member has more than one. Parse them, and compare by rank (owner, then admin, then member) rather than by string.
The global AuthGuard runs before this one, so req.auth is already there.
Personal accounts work the same way. If your product starts with one user per account, give each user an organization of their own anyway, created for them, which they never see. Everything still belongs to an organization, and turning on teams later needs no data migration.
5. The mobile app
For Expo, add the expo() plugin on the server, and the client plugin with secure storage in the app:
// The app has no web origin: it is trusted by its URL schemetrustedOrigins: [process.env.WEB_URL!, 'myapp://'],plugins: [expo(), /* … */],export const authClient = createAuthClient({ baseURL: API_URL, plugins: [ expoClient({ storagePrefix: 'auth', storage: SecureStore }), organizationClient(), ],});Mistakes to avoid
- Better Auth turns its origin check off when
NODE_ENVistest. Your tests then pass requests that production would refuse. Setadvanced: { disableOriginCheck: false }so tests check what production checks. - Rate limits in memory only hold on one server. Better Auth can keep them in your own storage, such as Redis, so they hold across instances.
- Two organizations created with the same slug at the same moment can both pass Better Auth’s “is it free?” check, and the second fails on the unique constraint with a 500. Claim the slug first (a short Redis lock works), and turn the database error into a 400 the user can act on.
- Its tables are its own. Better Auth reads its columns by name, so add columns if you need to, but do not rename or remove theirs.
This is how SaaS Stack does auth, with tests for signing in each way, unverified emails, untrusted origins, the mobile app’s scheme, and members of one workspace trying to reach another. The auth docs and workspaces docs say what the rest of the kit relies on.
All of this, already built
SaaS Stack comes with this done and tested, along with auth, teams, billing, emails, a phone app and deploy. Click through it in the live demo.