Auth pattern
Authentication pattern used in the stack: general and flexible design with a single JWT-based API auth flow; sign-in methods are pluggable. See stack rules auth-strategies, jwt, and password-hashing for the underlying technologies.
Reference: Backend auth with Passport (local + JWT) follows the NestJS Passport recipe: implementing Passport local and Passport JWT, and enabling authentication globally with a guard that skips when routes are marked public via IS_PUBLIC_KEY and a @Public() decorator (as in that section). Implement JwtAuthGuard and the Public decorator as shown there; register JwtAuthGuard as APP_GUARD so all endpoints require auth by default, and use @Public() on sign-in, refresh-token, health, and any other unauthenticated routes.
Documentation
- NestJS Passport (auth implementation): https://docs.nestjs.com/recipes/passport — use for Passport local, Passport JWT, global JwtAuthGuard, and @Public() / IS_PUBLIC_KEY pattern.
Overview
- Stateless API auth: No server-side session store. Identity is carried by JWTs. The same access/refresh tokens and transport apply regardless of how the user signed in; only the sign-in methods are pluggable.
- Stack: Passport (strategies for sign-in and for API auth) and @nestjs/jwt (sign/verify). Access token for API auth; optional refresh token for renewing without re-sign-in. Currently supported sign-in: email + password (bcryptjs; see
password-hashing), magic link (email link; see § Backend), Google SSO (passport-google-oauth2), and Microsoft SSO (passport-microsoft). - Transport: Access token via cookie (e.g.
user_token) and/or Authorization: Bearer (e.g. for mobile/Capacitor). CORS withcredentials: trueandcookie-parserin the backend.
Terminology
Use sign in and sign out (not “login”/“logout”) in user-facing copy, docs, and API naming when multiple sign-in methods are supported. “Sign in” is method-agnostic and matches provider wording (e.g. “Sign in with Google”); it works for email/password, magic link, and SSO. Use sign-in in identifiers (e.g. sign-in-form, POST /auth/sign-in, shared/auth/sign-in.ts). Pair with sign out for the inverse action.
Auth API (URL structure)
All auth endpoints live under /auth. Every successful sign-in (any method) issues the same access and refresh JWTs; only the way the user proves identity differs.
| Method | Path | Purpose |
|---|---|---|
GET | /auth/sign-in/methods | Returns available sign-in methods (e.g. { emailPassword: true, magicLink: true, google: boolean, microsoft: boolean }). Public. Frontend loads this to show/hide SSO and magic link options. |
POST | /auth/sign-in | Email + password. Body: { email, password }. Returns tokens and user. |
POST | /auth/sign-in/magic-link | Request magic link. Body: { email }. Sends email; no tokens in response. |
GET or POST | /auth/sign-in/magic-link/verify | Consume one-time token from email (query token=... or body { token }). Issues same JWT pair and cookie. |
GET | /auth/sign-in/sso/:provider | Start SSO (e.g. google, microsoft). Redirects user to provider. |
GET | /auth/sign-in/sso/:provider/callback | Provider redirects here with code; backend exchanges code, finds/creates user, issues same JWT pair and cookie, then redirects to app. |
POST | /auth/refresh-token | Refresh access token. Body: { refreshToken }. Same for all sign-in methods. |
POST | /auth/sign-out | (Optional) Revoke refresh token, clear server-side state; client clears tokens and cookie. |
Protected routes and “current user” are not sign-in; e.g. GET /auth/me or GET /users/me for the authenticated user when needed.
Shared models (theme directory)
Auth is a theme, not a single entity. Request/response shapes for auth live under shared/auth/ per the models pattern: e.g. shared/auth/sign-in.ts (sign-in body: signInSchema, SignInDto), shared/auth/refresh-token.ts (refresh request body: refreshTokenSchema, RefreshTokenDto), shared/auth/sign-in-methods.ts (response of GET /auth/sign-in/methods: SignInMethods, signInMethodsSchema), shared/auth/magic-link.ts (request body requestMagicLinkSchema / RequestMagicLinkDto for POST /auth/sign-in/magic-link, verifyMagicLinkSchema / VerifyMagicLinkDto for verify), shared/auth/sign-in-response.ts (AuthUser, SignInResponse). Use these schemas and types in backend validation and frontend forms so both stay aligned. See pattern models.
Implementation
The boilerplate provides sign-in form UI (shadcn login-02 block, then edited with snippet-based steps: Card, CardHeader, CardContent, Form and field components, Google and Microsoft SSO buttons and a "Sign in by email" link when enabled from GET /auth/sign-in/methods; no icons in the SSO/email buttons; tighter vertical gap between those buttons; more vertical margin around the "Or continue with" separator; no "Don't have an account? Sign up" link or "Terms of Service and Privacy Policy" footer on sign-in or sign-in/email pages) and a dashboard (shadcn sidebar-07 block, then stripped: collapsible sidebar at / with Logo in header, NavMain only (no NavProjects), NavUser with only "Log out" (no Avatar or other menu items); TeamSwitcher and nav-projects components are removed) on the desktop app. The app exposes the dashboard at /, sign-in at /auth/sign-in, and magic link sign-in at /auth/sign-in/email. The sign-in page is wrapped in a full-height centered container with an inner w-full max-w-sm md:max-w-4xl wrapper. The auth service calls POST /auth/sign-in (see § Auth API). Use “Sign in” / “Sign out” in user-facing copy (see § Terminology).
Backend
The backend implements JWT (access + refresh), Passport (local strategy for email/password sign-in, JWT strategy for API auth), cookie user_token, and Bearer extraction, following the NestJS Passport recipe. Authentication is enabled globally by default: register JwtAuthGuard as APP_GUARD (as in the recipe’s Enable authentication globally section). Use the IS_PUBLIC_KEY metadata and a @Public() decorator to mark routes as public; JwtAuthGuard checks that metadata (via Reflector) and skips auth when present. Implement JwtAuthGuard and Public as in the linked docs rather than custom snippets. AuthModule provides GET /auth/sign-in/methods (returns SignInMethods: { emailPassword: true, magicLink: true, google: boolean, microsoft: boolean } from AppConfigService.isGoogleSsoEnabled() and AppConfigService.isMicrosoftSsoEnabled()), POST /auth/sign-in (body { email, password }), POST /auth/sign-in/magic-link (body { email }; creates short-lived one-time token, stores in DB, sends email with link; response { message: "Check your email" }), GET and POST /auth/sign-in/magic-link/verify (query token or body { token }; validate token, find-or-create user by email, issue same JWT pair and set cookie; GET redirects to frontend with tokens in fragment), POST /auth/refresh-token (body { refreshToken }), GET /auth/me, GET /auth/sign-in/sso/google and GET /auth/sign-in/sso/google/callback for Google SSO, and GET /auth/sign-in/sso/microsoft and GET /auth/sign-in/sso/microsoft/callback for Microsoft SSO (when configured); sign-in, sign-in/methods, magic-link, refresh-token, SSO routes, and the root route are decorated @Public(). When GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET are empty, Google SSO is disabled: AppConfigService.isGoogleSsoEnabled() is false, GoogleSsoEnabledGuard returns 501 on the Google routes, and GoogleStrategy (passport-google-oauth2) uses placeholder credentials so the app starts. When MICROSOFT_CLIENT_ID or MICROSOFT_CLIENT_SECRET are empty, Microsoft SSO is disabled: AppConfigService.isMicrosoftSsoEnabled() is false, MicrosoftSsoEnabledGuard returns 501 on the Microsoft routes, and MicrosoftStrategy (passport-microsoft) uses placeholder credentials so the app starts. The JWT strategy extracts the access token from cookie user_token or Authorization: Bearer. User store is a Prisma-based repository (UsersRepository in backend/src/auth/users.repository.ts) using the User model (id, email, displayName, passwordHash optional—null for SSO/magic-link-only users); auth requires a database—run migrations and seed (e.g. prisma migrate deploy and prisma db seed; the root dev script runs both) so demo users exist for local development.
Frontend auth state
AuthProvider (React context) in frontends/shared (auth-context.tsx) holds isAuthenticated, user, signIn(payload), signOut. Tokens and user are persisted in localStorage (keys in auth-storage.ts) so a refresh keeps the user signed in. The API client sets withCredentials: true and a request interceptor adds Authorization: Bearer from localStorage so both cookie and Bearer work (e.g. for direct calls like exports). On 401, a response interceptor clears stored auth and invokes a callback (registered by AuthProvider) that calls signOut(), so the AuthGuard redirects to sign-in.
Auth guard
An AuthGuard component (desktop: components/AuthGuard.tsx) protects routes: if !isAuthenticated it renders Navigate to /auth/sign-in; otherwise Outlet. The dashboard route is the only protected route; the sign-in page is not behind the guard.
Sign-out (Log out) in sidebar
The sidebar footer (desktop: nav-user.tsx) shows only a "Log out" control that calls useAuth().signOut() (no Avatar, no user name/email, no other menu items). signOut clears localStorage and auth state; AuthGuard then redirects to /auth/sign-in.
Sign-in request flow (frontend → backend)
The sign-in form validates with signInSchema, calls login(body) (POST /auth/sign-in). The backend validates with SignInDto (ZodValidationPipe), validates credentials via the local Passport strategy, then issues access + refresh JWTs, sets user_token cookie, and returns { user, accessToken, refreshToken }. The form onSuccess calls signIn(response.data) to persist tokens and user to localStorage and updates state, then navigates to the dashboard.
Magic link
When signInMethods.magicLink is true, the main sign-in page (/auth/sign-in) shows a "Sign in by email" button (styled like the SSO buttons) that links to /auth/sign-in/email. The magic link form lives on that separate page only: /auth/sign-in/email shows Logo, a card with email input, "Send magic link" button, and on success "Check your email for the sign-in link."; at the bottom a "Back to sign in" link to /auth/sign-in. On submit the frontend calls POST /auth/sign-in/magic-link with { email }; the backend creates a short-lived one-time token (e.g. 15 min), stores it (e.g. MagicLinkToken in Prisma), and sends an email with a link to FRONTEND_URL/auth/sign-in?magic_link_token=... or FRONTEND_URL/auth/sign-in/email?magic_link_token=... (or to the backend GET /auth/sign-in/magic-link/verify?token=... which redirects to the frontend with tokens in the fragment). When the user lands on /auth/sign-in or /auth/sign-in/email with magic_link_token in the query, the page calls POST /auth/sign-in/magic-link/verify with { token }; on success it calls signIn(response.data) and navigates to the dashboard (and clears the query to avoid re-use). If the email link points at the backend GET verify, the backend redirects to the frontend with tokens in the fragment and the existing SSO callback handler completes sign-in.
Google SSO and Microsoft SSO
The sign-in page includes “Sign in with Google” and “Sign in with Microsoft” buttons when the corresponding method is enabled (from GET /auth/sign-in/methods). Each button navigates to GET /auth/sign-in/sso/google or GET /auth/sign-in/sso/microsoft (URL built from the same API base as the client). The backend redirects to the provider; after consent, the provider redirects to the callback (e.g. GET /auth/sign-in/sso/google/callback or GET /auth/sign-in/sso/microsoft/callback). The backend exchanges the code, finds or creates a user by email (e.g. UsersRepository.findOrCreateSsoUser), issues the same JWT pair and sets user_token cookie, then redirects to the frontend with tokens and user in the URL fragment (e.g. /auth/sign-in#access_token=...&refresh_token=...&user=...). The sign-in page (or callback handler) parses the fragment on load, calls signIn({ user, accessToken, refreshToken }), and navigates to the dashboard. The API client already sends Bearer from storage and handles 401.
Shared auth types
shared/auth/sign-in provides signInSchema and SignInDto; shared/auth/refresh-token provides refreshTokenSchema and RefreshTokenDto; shared/auth/sign-in-methods provides SignInMethods and signInMethodsSchema (GET /auth/sign-in/methods response; includes magicLink: true); shared/auth/magic-link provides requestMagicLinkSchema, RequestMagicLinkDto, verifyMagicLinkSchema, VerifyMagicLinkDto; shared/auth/sign-in-response provides AuthUser and SignInResponse. AuthUser has id, email, and displayName (required); displayName is set from the SSO profile (e.g. Google/Microsoft displayName) when applicable, otherwise falls back to email.
Backend
- Available methods:
GET /auth/sign-in/methods(public) returns SignInMethods ({ emailPassword: true, magicLink: true, google: boolean, microsoft: boolean }). Thegoogleandmicrosoftflags are AppConfigService.isGoogleSsoEnabled() and AppConfigService.isMicrosoftSsoEnabled(); magicLink is always true. The frontend uses this to show the email/password form, the "Send magic link" option, and when enabled the "Sign in with Google" and "Sign in with Microsoft" options (no Apple or Meta). - Sign-in (email/password, magic link, Google SSO, Microsoft SSO)
POST /auth/sign-in(see § Auth API) with body{ email, password }→ bcrypt compare → on success, issue the same JWT pair (access + refresh) and set access in cookie; return user (e.g.AuthUser). Optionally return both tokens in body for clients that need the refresh token.- Magic link:
POST /auth/sign-in/magic-linkwith body{ email }. Backend generates a short-lived one-time token (e.g. 15 min), stores it in a MagicLinkToken table (token, email, expiresAt, usedAt), and sends an email with a link to FRONTEND_URL/auth/sign-in?magic_link_token=... or to BACKEND_URL/auth/sign-in/magic-link/verify?token=.... Response:{ message: "Check your email" }(no tokens).GET /auth/sign-in/magic-link/verify?token=...andPOST /auth/sign-in/magic-link/verify(body{ token }): validate token (unused and not expired), mark used, find or create user by email (same as SSO), issue the same JWT pair and set cookie; GET redirects to FRONTEND_URL with tokens and user in the URL fragment; POST returns SignInResponse. Both magic-link routes are @Public(). Requires MailingModule and Prisma (or equivalent) for token storage. - Google SSO:
GET /auth/sign-in/sso/googlestarts the flow (passport-google-oauth2 redirects to Google).GET /auth/sign-in/sso/google/callbackreceives the code; backend exchanges it, gets profile (email, displayName), find or create user (e.g. UsersRepository.findOrCreateSsoUser by email), calls login(user) to issue the same JWT pair, sets user_token cookie, then redirects to FRONTEND_URL withaccess_token,refresh_token, anduserin the URL fragment. Env: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET. When either is empty, GoogleSsoEnabledGuard returns 501 and GoogleStrategy uses placeholder credentials. - Microsoft SSO:
GET /auth/sign-in/sso/microsoftstarts the flow (passport-microsoft redirects to Microsoft).GET /auth/sign-in/sso/microsoft/callbackreceives the code; backend exchanges it, gets profile (e.g. userPrincipalName, displayName, name), find or create user via findOrCreateUserFromMicrosoft (user displayName set from profile displayName or name when applicable), issues the same JWT pair and cookie, then redirects to FRONTEND_URL with tokens and user in the fragment. Env: MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET. When either is empty, MicrosoftSsoEnabledGuard returns 501 and MicrosoftStrategy uses placeholder credentials.
- Protected routes: Enable authentication globally by registering JwtAuthGuard (extending
AuthGuard('jwt')) as APP_GUARD. Implement the guard and the @Public() decorator using IS_PUBLIC_KEY and Reflector as in the NestJS Passport recipe — Enable authentication globally. Passport JWT strategy (seeauth-strategies,jwt) extracts token from: (1) cookieuser_token, (2)Authorization: Bearer. Validate JWT with access-token secret; attach payload to request (e.g.id,roles). Mark sign-in, refresh-token, health, and other unauthenticated routes with @Public(). - Refresh:
POST /auth/refresh-tokenwith body{ refreshToken }(no cookie). Verify refresh JWT with refresh-token secret; issue new access + refresh pair. Same regardless of original sign-in method. - Config: Auth uses AppConfigService.get(key) (see backend
configuration). Env vars are in the general app config schema:JWT_ACCESS_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION,JWT_REFRESH_TOKEN_SECRET,JWT_REFRESH_TOKEN_EXPIRATION,JWT_COOKIE_NAME; for Google SSO:GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET; for Microsoft SSO:MICROSOFT_CLIENT_ID,MICROSOFT_CLIENT_SECRET. BACKEND_URL and FRONTEND_URL are derived from HOST/PORT and FRONTEND_HOST/FRONTEND_PORT (or from APP_URL in deployment); SSO callback paths are fixed (e.g./auth/sign-in/sso/google/callback,/auth/sign-in/sso/microsoft/callback).
Configuring Google OAuth
To enable “Sign in with Google”, create an OAuth 2.0 client in Google Cloud Console → APIs & Services → Credentials → Create credentials → OAuth client ID (application type: Web application). Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in your env (or GitLab CI for deployment).
Google requires the redirect URI sent by the app to match exactly one of the client’s Authorized redirect URIs. Add each environment you use:
-
Local development
The backend uses BACKEND_URL =http://<HOST>:<PORT>(defaulthttp://localhost:3000). Add:http://localhost:3000/auth/sign-in/sso/google/callback
If your dev backend runs on another port (e.g. 3001), add that too:http://localhost:3001/auth/sign-in/sso/google/callback.
-
Deployed (acceptance / production)
With same-origin deployment the backend is under/api, so BACKEND_URL = APP_URL +/api. Add one redirect URI per app URL, for example:https://boilerplate.unified-stack.solusa.com/api/auth/sign-in/sso/google/callback
For each deployed app (e.g. acc and prod), add the exact URI:https://<that-app’s-domain>/api/auth/sign-in/sso/google/callback.
You can list multiple redirect URIs on the same OAuth client (localhost for dev and each deployed app URL). Save the client after adding the URIs; a 400: redirect_uri_mismatch error means the URI sent by the app is not in that list or differs (scheme, host, port, or path).
Configuring Microsoft
To enable "Sign in with Microsoft", register an app in Azure Portal → Microsoft Entra ID (Azure AD) → App registrations → New registration. Under Authentication, add a Web platform redirect URI. Set MICROSOFT_CLIENT_ID (Application (client) ID), MICROSOFT_CLIENT_SECRET (client secret from Certificates & secrets), and optionally MICROSOFT_TENANT_ID in your env (or GitLab CI for deployment).
MICROSOFT_TENANT_ID (optional but required for single-tenant):
- Single-tenant (e.g. "Accounts in this organizational directory only" / "Alleen een tenant - standaardmap"): set MICROSOFT_TENANT_ID to your Entra tenant ID (a GUID). The backend then uses
https://login.microsoftonline.com/{tenant-id}/v2.0for issuer and token endpoints, and the ID tokenisswill match. To find it: Azure Portal → Microsoft Entra ID → Overview → copy Tenant ID (or from your app registration → Overview → Directory (tenant) ID). - Multi-tenant (e.g. "Accounts in any organizational directory"): leave MICROSOFT_TENANT_ID empty; the backend uses the common endpoint (
.../common/v2.0). Do not use "Consumer" / personal-only with/common/— that combination is not supported by Microsoft.
The backend is a confidential client (server keeps the secret). If you see AADSTS90023: Public clients can't send a client secret, the app is registered as a public client. In the app’s Authentication → Advanced settings, set Allow public client flows to No. In the Manifest, set allowPublicClient to false (or remove it), then save.
The redirect URI must match exactly:
- Local development:
http://localhost:3000/auth/sign-in/sso/microsoft/callback(or your backend port). - Deployed:
https://<APP_URL>/api/auth/sign-in/sso/microsoft/callback(e.g.https://your-app.ondigitalocean.app/api/auth/sign-in/sso/microsoft/callback).
Tokens and “session”
- Access token: Short-lived (default 24h, configurable in seconds via
JWT_ACCESS_TOKEN_EXPIRATION). Used on every API request (cookie sent automatically withwithCredentials, or Bearer from storage). No revocation except expiry (or optional blocklist if added later). - Refresh token: Longer-lived. Used only to call
/auth/refresh-token; not sent on normal API requests. Enables renewing access without re-entering password. - Session: No server session store; “session” is the validity of the access token (and cookie). Frontend may store user info locally and re-validate (e.g.
getUser/validateAuth) and optionally call refresh before access expires or on 401.
Frontend
- Requests:
withCredentials: trueso cookie is sent when same-origin or allowed CORS origin. For clients that don’t use cookies (e.g. Capacitor), store access token (e.g. Capacitor Preferences / localStorage) and sendAuthorization: Bearer <accessToken>via a custom token retriever. Usehttp-client(Axios) with the same base URL and interceptors for auth. - After sign-in: If server sets cookie, browser gets it automatically; some frontends also copy access token to local storage for Bearer fallback. If server returns refresh token in body, store it (e.g. in memory or secure storage) for the refresh flow.
- 401 handling: Clear stored tokens and user; redirect to sign-in. Optionally: on 401, try
POST /auth/refresh-tokenwith stored refresh token; on success update access (and cookie if applicable) and retry the failed request; on failure, then clear and redirect.
Optional extensions
- Multiple Passport strategies: JWT for user auth; passport-custom (or API-key) strategies for machine-to-machine or webhook routes. Use separate guards and
@UseGuards(…)or route-level guards for those routes incontroller-layer. - Roles/capabilities: JWT payload can include
roles; use a role/capability guard or decorator in addition to the JWT guard for authorization.
Future extensions: other sign-in methods
Keep the pattern general: after any successful sign-in, the backend issues the same access + refresh JWTs and uses the same cookie/Bearer transport. New sign-in methods only change how the user is identified before issuing that pair. Use the paths in § Auth API (URL structure).
-
Magic link
- Implemented in the boilerplate (see § Implementation and § Backend).
-
SSO (e.g. Google)
- Google is implemented in the boilerplate (see § Implementation and § Backend).
- Start:
GET /auth/sign-in/sso/:provider(e.g./auth/sign-in/sso/google). Use a Passport strategy for the provider (e.g. passport-google-oauth2 for Google, passport-microsoft for Microsoft). Backend redirects user to the provider; after consent, provider redirects back with an authorization code. - Callback:
GET /auth/sign-in/sso/:provider/callback. Backend exchanges code for provider tokens, gets user profile (e.g. email), find or create local user (e.g. by email), issue the same JWT pair and set cookie or redirect frontend with tokens (e.g. fragment or query). From then on, API auth is identical (JWT in cookie or Bearer). - Optional: link SSO identity to existing email/password account (e.g. same email); store provider id in user table for “Sign in with Google” next time.
- Other providers (e.g. Microsoft): same pattern; add strategy and env; requires provider app credentials (env), callback URL, and possibly a small “accounts” or “identities” model if supporting multiple providers per user.