A Turborepo monorepo with a NestJS backend authenticated by Better Auth, backed by Drizzle ORM + PostgreSQL.
Authentication is split into two shared packages consumed by the NestJS app, so the DB connection and the auth instance each have a single source of truth:
apps/
backend/ NestJS 11 (Express). Mounts Better Auth via
│ @thallesp/nestjs-better-auth → exposes /api/auth/*
│ ▲ depends on
└── web/ TanStack Start app (auth client wiring is a follow-up)
packages/
auth/ (@repo/auth) betterAuth({ ... }) instance + inferred Session/User types
│ ▲ depends on
db/ (@repo/db) Drizzle client (pg Pool) + generated auth schema + migrations
ui/ eslint-config/ typescript-config/ (shared tooling)
@repo/dbowns the PostgreSQL connection (db) and the Drizzle schema. The auth tables (user,session,account,verification) are generated by the Better Auth CLI intopackages/db/src/schema/auth.ts.@repo/authbuilds thebetterAuthinstance on top of@repo/dbusingdrizzleAdapter(db, { provider: "pg", schema }), and exports the inferredSession/Usertypes. Email & password auth is enabled with public sign-up disabled (disableSignUp: true), LDAP/AD login is provided bybetter-auth-credentials-plugin(see LDAP / Active Directory login), and all cookies use the prefixsme-bismillah(e.g.sme-bismillah.session_token).apps/backendregistersAuthModule.forRoot({ auth }); the library mounts all/api/auth/*routes and installs a globalAuthGuard.
@repo/db and @repo/auth are compiled packages (tsc → dist/). Turbo's build.dependsOn: ["^build"] builds them before the backend, whose nest build (plain tsc, no bundler) consumes their emitted JS + declarations.
Requires Node ≥ 18 (Node ≥ 22 recommended — the auth package is ESM-only and relies on native require(esm)), pnpm 9, and a PostgreSQL database.
pnpm install
# 1. Create the database (example uses a db named `sme_db`)
createdb -U postgres sme_db # or: CREATE DATABASE sme_db;
# 2. Configure env (see "Environment variables" below)
cp .env.example apps/backend/.env # runtime
cp .env.example packages/db/.env # drizzle-kit CLI
# → set DATABASE_URL, e.g. postgres://postgres:<password>@localhost:5432/sme_db
# → set BETTER_AUTH_SECRET (generate one, see below)
# 3. Create the auth tables
pnpm --filter @repo/db db:migrate
# 4. Run the backend
pnpm --filter backend start:dev # http://localhost:3001Generate a secret for BETTER_AUTH_SECRET with the Better Auth CLI:
pnpm --filter @repo/db exec better-auth secretValidated with zod at import time (packages/db/src/env.ts, packages/auth/src/env.ts):
| Variable | Used by | Description |
|---|---|---|
DATABASE_URL |
@repo/db |
PostgreSQL connection string |
BETTER_AUTH_SECRET |
@repo/auth |
Secret used to sign session cookies (≥ 16 chars; ≥ 32 recommended) |
BETTER_AUTH_URL |
@repo/auth |
Base URL the auth server runs at, e.g. http://localhost:3001 |
TRUSTED_ORIGINS |
@repo/auth, CORS |
Comma-separated origins allowed to send credentialed requests |
PORT |
apps/backend |
Backend HTTP port (default 3001) |
LDAP_URL |
@repo/auth |
LDAP server URL (default: public Forum Systems test server) |
LDAP_BIND_DN |
@repo/auth |
Service account DN used to search for users |
LDAP_BIND_PASSWORD |
@repo/auth |
Password for the bind DN |
LDAP_SEARCH_BASE |
@repo/auth |
Base DN searched for user entries |
LDAP_USERNAME_ATTRIBUTE |
@repo/auth |
Attribute matched against the email's local part (default uid; use sAMAccountName for AD) |
LDAP_EMAIL_DOMAINS |
@repo/auth |
Comma-separated email domains that authenticate via LDAP |
All LDAP_* variables default to the free public Forum Systems LDAP test server, so LDAP login works out of the box in development.
Mounted by @thallesp/nestjs-better-auth under /api/auth:
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/auth/sign-up/email |
Disabled → 400 EMAIL_PASSWORD_SIGN_UP_DISABLED |
| POST | /api/auth/sign-in/email |
Sign in with email & password; sets the session cookie |
| POST | /api/auth/sign-in/credentials |
Sign in via LDAP/AD (domains in LDAP_EMAIL_DOMAINS only) |
| POST | /api/auth/sign-out |
Clear the session (requires an Origin header) |
| GET | /api/auth/get-session |
Current session (or null) |
Example sign-in:
curl -X POST http://localhost:3001/api/auth/sign-in/email \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","password":"a-strong-password"}'Public endpoint — no authentication required.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /mobile/version |
Hybrid OTA / APK update check for mobile clients |
| Parameter | Required | Example | Description |
|---|---|---|---|
currentVersion |
No | 1.0.0 |
Installed app version. Omit for legacy clients |
platform |
No | android |
Target platform (default: android) |
runtimeVersion |
No | 1.0.0 |
Expo runtime version of the client app |
channel |
No | production |
OTA release channel (production, preview, staging) |
The backend compares currentVersion against minimumVersion and latestVersion in the database, then returns one of three updateType values:
ota— Expo OTA update available; includeschannelandruntimeVersion.apk— Standalone APK update required (used when belowminimumVersionor whenupdateType=apkis stored); includesdownloadUrl,checksum,fileSize.none— App is already on the latest or newer version.
# Client with version check
curl "http://localhost:3001/mobile/version?currentVersion=1.0.0&platform=android&channel=production"
# Legacy client (backward compatible — no currentVersion)
curl "http://localhost:3001/mobile/version"Example OTA response:
{
"updateAvailable": true,
"updateType": "ota",
"forceUpdate": false,
"minimumVersion": "1.0.0",
"latestVersion": "1.0.1",
"releaseNotes": "Bug fixes and performance enhancements",
"channel": "production",
"runtimeVersion": "1.0.0",
"downloadUrl": "https://example.com/downloads/app-release.apk",
"updateUrl": "https://example.com/downloads/app-release.apk",
"checksum": "e3b0c44...",
"fileSize": 15420000,
"publishedAt": "2026-08-03T10:00:00.000Z",
"isActive": true
}Example APK / force-update response:
{
"updateAvailable": true,
"updateType": "apk",
"forceUpdate": true,
"minimumVersion": "1.0.0",
"latestVersion": "2.0.0",
"releaseNotes": "Major release — APK update required",
"version": "2.0.0",
"downloadUrl": "https://example.com/downloads/app-v2.apk",
"checksum": "sha256-hash",
"fileSize": 20000000,
"publishedAt": "2026-08-03T10:00:00.000Z",
"isActive": true
}| Variable | Description | Default |
|---|---|---|
MOBILE_OTA_CHANNEL |
Default Expo OTA channel when not provided by client | production |
MOBILE_OTA_RUNTIME_VERSION |
Default Expo runtime version when not stored in DB | 1.0.0 |
MOBILE_APK_DOWNLOAD_URL |
Fallback APK download URL when not stored in DB | (empty) |
Public self-service registration is disabled, so the sign-up/email endpoint is
closed. Create users through the server-side auth API instead — from a script or
an admin-only route using the internal adapter:
import { auth } from '@repo/auth';
const ctx = await auth.$context;
const password = await ctx.password.hash('a-strong-password');
const user = await ctx.internalAdapter.createUser({
email: 'admin@example.com',
name: 'Admin',
emailVerified: true,
});
await ctx.internalAdapter.linkAccount({
userId: user.id,
providerId: 'credential',
accountId: user.id,
password,
});Accounts available for local development and testing the login page:
| Password | Flow | |
|---|---|---|
admin@example.com |
a-strong-password |
Email & password (providerId: credential) |
einstein@ldap.forumsys.com |
password |
LDAP (providerId: ldap) |
admin@example.com was created manually with the script above. The LDAP user is
auto-provisioned on first login against the public Forum Systems test server —
other test users (newton, tesla, gauss, euler, …, all with password
password) work the same way. The web login form routes by email domain
(VITE_LDAP_EMAIL_DOMAINS), so both flows go through the same form.
POST /api/auth/sign-in/credentials (added by better-auth-credentials-plugin)
authenticates against an LDAP directory instead of the local password table.
The flow, implemented in packages/auth/src/ldap.ts + packages/auth/src/auth.ts:
- The email's domain must be listed in
LDAP_EMAIL_DOMAINS, otherwise the request is rejected — regular users keep usingsign-in/email. - The email's local part is matched against
LDAP_USERNAME_ATTRIBUTE(einstein@ldap.forumsys.com→ searchuid=einstein), then the entry is bound with the submitted password. - On first successful login the user is auto-provisioned (
autoSignUp) withemailVerified: true; theaccountrow usesproviderId: "ldap", separate from the"credential"password flow. No extra tables are needed, sopackages/db/better-auth.config.tsintentionally omits the plugin.
With the default env values you can try it against the public Forum Systems
test server — users einstein, newton, tesla, gauss, euler, … all with
password password:
curl -X POST http://localhost:3001/api/auth/sign-in/credentials \
-H "Content-Type: application/json" \
-d '{"email":"einstein@ldap.forumsys.com","password":"password"}'A frontend can route by domain using the exported helper:
import { isLdapEmail } from '@repo/auth';
// authClient must include the plugin:
// createAuthClient({ plugins: [credentialsClient()] })
// with credentialsClient from "better-auth-credentials-plugin/client"
if (isLdapEmail(email)) {
await authClient.signIn.credentials({ email, password }); // LDAP/AD
} else {
await authClient.signIn.email({ email, password }); // local password
}Production: point the
LDAP_*vars at your own directory — useldaps://host:636, setLDAP_USERNAME_ATTRIBUTE=sAMAccountName(oruserPrincipalName) for Active Directory, and restrictLDAP_EMAIL_DOMAINSto your company domain. The Forum Systems server is a public read-only test box; never leave the defaults enabled in production.
A global AuthGuard is registered by AuthModule.forRoot, so every route is protected by default and returns 401 without a valid session cookie.
import { Controller, Get } from '@nestjs/common';
import { Public, Session, type UserSession } from '@thallesp/nestjs-better-auth';
@Controller()
export class AppController {
// Opt a route out of the global guard:
@Public()
@Get()
getHello(): string {
return 'Hello World!';
}
// Protected — 401 without a session cookie. Read the current user:
@Get('me')
getMe(@Session() session: UserSession) {
return session.user;
}
}For server-side auth API calls, inject AuthService<typeof auth> and pass request headers via fromNodeHeaders(req.headers).
Note:
apps/backend/src/main.tscreates the app with{ bodyParser: false }so Better Auth can read raw request bodies; the library re-adds the default parsers for all other routes. CORS is enabled withcredentials: trueandTRUSTED_ORIGINS. Session cookies arehttpOnly+sameSite=lax, prefixed withsme-bismillah(and__Secure-in production whenNODE_ENV=production).
When you change auth options/plugins, keep packages/db/better-auth.config.ts in sync with packages/auth/src/auth.ts, then:
pnpm --filter @repo/db auth:generate # npx @better-auth/cli → src/schema/auth.ts
pnpm --filter @repo/db db:generate # drizzle-kit → SQL migration in drizzle/
pnpm --filter @repo/db db:migrate # apply to the databasedb:studio opens Drizzle Studio for the configured database.
pnpm turbo build # build all packages/apps
pnpm turbo lint # lint
pnpm turbo check-types # typecheck packages
pnpm --filter backend test