Authentication
PCMR.gr uses Better Auth v1.5.5 for all authentication. Better Auth runs inside NestJS (not Next.js), making NestJS the single source of truth for sessions.
Configuration (src/auth/auth.ts)
Why baseURL is the frontend URL
baseURL: process.env.BETTER_AUTH_URL; // must be the FRONTEND URL
Better Auth uses baseURL to construct links in verification emails (e.g., password reset, email verification). If set to the API URL, those links would point to the API instead of the frontend. Since the frontend proxies all /api/auth/* to NestJS, setting baseURL to the frontend URL works correctly.
BETTER_AUTH_URL must be set to the same frontend public URL in both the API and web containers.
Session cookies
Cookie config:
cookie: {
prefix: 'better-auth',
domain: process.env.COOKIE_DOMAIN || undefined,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
}
Cookie names:
- HTTPS production:
__Secure-better-auth.session_token - Local dev (HTTP):
better-auth.session_token
COOKIE_DOMAIN: Must be empty string (not set) for local dev — browsers reject Domain=localhost. In production: .pcmr.gr (leading dot = all subdomains, so both pcmr.gr and staff.pcmr.gr share the session).
Custom user fields
user: {
additionalFields: {
mustChangePassword: { type: 'boolean', input: false },
firstName: { type: 'string', input: true },
lastName: { type: 'string', input: true },
},
}
mustChangePassword is input: false — it can never be set via the client API, only by server-side code (e.g., create-admin.js script).
databaseHooks.user.create.before
Before any user is created, name is computed:
- For regular registration:
firstName + " " + lastName - For Google OAuth (no
firstName/lastName): splits the Googlenamefield into parts
Domain policy (src/auth/utils/domain-policy.ts)
const STAFF_DOMAIN = 'ctsolutions.gr';
export function isStaffDomain(email: string): boolean { ... }
A before /sign-up/email hook blocks @ctsolutions.gr addresses from self-registering. Staff accounts are created by CLI only.
RolesGuard also enforces that admin and superadmin roles must have a @ctsolutions.gr email.
Plugins
admin()
Enables Better Auth's admin plugin — provides session revocation, user management API that AdminUsersService uses under the hood.
twoFactor()
TOTP-based 2FA. Required for all staff accounts as part of the first-login flow.
Auth Controller (src/auth/auth.controller.ts)
The controller is a single @All('*path') catch-all on api/auth/*:
@SkipThrottle()— global 100 req/min limiter is bypassed (auth has its own Express-level rate limits)- Converts the Express
Requestto a Web APIRequestobject - Calls
auth.handler(webRequest) - Converts the Web API
Responseback to the Express response - Handles
Set-Cookieheaders viagetSetCookie()(polyfill required for multi-value cookie support in Node < 21) - Always sets
Cache-Control: no-storeon auth responses
Social Providers
Google OAuth is configured for customer accounts only. Staff must use email/password + TOTP — they cannot log in via Google.
First-Login Flow (Staff)
New staff accounts are created with mustChangePassword: true. The FirstLoginGuard enforces the following steps before the account can access the portal:
- Change temporary password —
mustChangePassword === true→MUST_CHANGE_PASSWORDerror code - Verify email —
!user.emailVerified→EMAIL_NOT_VERIFIEDerror code - Set up TOTP 2FA —
!user.twoFactorEnabled→TOTP_SETUP_REQUIREDerror code
The web app's staff first-login pages (/staff/first-login/*) handle each step in sequence.
Staff Account Creation
Staff accounts are never created via the UI. Only via the CLI script:
sudo docker exec -it <api_container> sh -c \
Valid roles for staff: admin, superadmin. Role user is for customers.
The 2FA Cookie Bug Workaround
Problem: [email protected] has a bug where headers.set('set-cookie') overwrites previous values. When verifyTotp sets two cookies (session token + 2FA state), only the last one survives, leaving the user without a valid session.
Workaround: apps/web/src/app/api/2fa-verify/route.ts is not a standard proxy. After calling the Better Auth TOTP verify endpoint, it:
- Reads the response cookies
- Manually signs the session token using
BETTER_AUTH_SECRET - Sets the
__Secure-better-auth.session_tokencookie directly on the Next.js response
This means /api/2fa-verify must not be treated as a simple proxy — it has security-critical logic.
Server-Side Session Validation (Next.js)
apps/web/src/lib/auth-server.ts exports getServerSession():
const res = await fetch(`${API_URL}/api/auth/get-session`, {
headers: { cookie: cookieHeader, origin: BETTER_AUTH_URL },
cache: "no-store",
});
Uses the internal API_URL (not public NEXT_PUBLIC_API_URL) to avoid the public internet / Cloudflare round-trip from server components.
Rate Limits
| Endpoint | Limit | Window |
|---|---|---|
POST /api/auth/sign-in/email | 10 req | 15 min per IP |
POST /api/auth/sign-up/email | 5 req | 1 hr per IP |
All other /api/* | 100 req | 1 min per IP |