End-to-End Authentication: Designing a Secure Login System
Authentication done right touches the frontend, backend, database, and infrastructure simultaneously. This walks through the full stack — session flow, JWT validation, user models, and the hardening decisions that make it production-safe.
Building authentication for a decoupled architecture where Next.js handles the frontend and NestJS powers the backend API requires careful orchestration. This guide covers the complete implementation of a production-ready auth system using NextAuth, PostgreSQL, and Prisma—with a focus on security best practices and scalable patterns.
Prerequisites
To get the most out of this guide, you should be familiar with:
- Next.js 13/14: App Router and Server Components.
- NestJS: Modules, Controllers, and Services.
- Docker: Basic container management (for running the microservices).
- Node.js: v18 or later.
Part 1: Architecture Overview
Before diving into code, it's crucial to understand the high-level architecture. We'll look at how the different pieces of our system—the Next.js frontend, the API Gateway, and the Auth Microservice—interact to provide a seamless and secure user experience.
1.1 The Challenge
When building a modern SaaS application, you often want the best of both worlds: Next.js for its server-side rendering and excellent developer experience, and NestJS for its robust, scalable backend architecture. The challenge is making them share authentication state securely.
NextAuth.js is designed primarily for Next.js, managing sessions on its own terms. NestJS typically relies on standard JWT Bearer tokens. Bridging this gap—making NestJS "trust" sessions created by NextAuth without direct database sharing—is the core problem we solve.
1.2 System Components
- Frontend (Next.js 14): Handles OAuth flows via NextAuth. Manages session state and forwards requests to the API Gateway.
- API Gateway (NestJS): Stateless entry point that routes requests to microservices.
- Auth Microservice (NestJS): Dedicated service handling authentication logic and database interactions via Prisma.
- Database (PostgreSQL Server): Single source of truth managed exclusively by the Auth Microservice.
1.3 Communication Flow
┌──────────────┐ HTTP ┌──────────────┐ TCP ┌──────────────┐
│ Next.js │ ──────────────▶ │ API Gateway │ ──────────────▶ │ Auth │
│ REST Adapter │ ◀────────────── │ Controller │ ◀────────────── │ Microservice │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
│ Prisma
▼
┌──────────────┐
│ PostgreSQL │
└──────────────┘
1.4 Authentication Sequence
User Next.js (Client) API Gateway Auth Service Database
│ │ │ │ │
├─── Login ──────▶│ │ │ │
│ │── POST /login ────▶│ │ │
│ │ │── TCP /login ────▶│ │
│ │ │ │─── Query User ─▶│
│ │ │ │◀── User Data ───│
│ │ │◀── JWT Tokens ────│ │
│ │◀── JWT Tokens ─────│ │ │
│◀── Session ─────│ │ │ │
│ (Cookie) │ │ │ │
Part 2: Frontend Implementation
The frontend is the user's entry point. Here, we'll configure NextAuth.js to handle the complexities of OAuth and session management, but with a twist: instead of talking directly to a database, we'll build a custom adapter that communicates with our backend API.
2.1 Custom REST Adapter
In a typical Next.js app, you'd use PrismaAdapter to let NextAuth talk directly to your database. But in a microservices architecture, the database should be private to backend services. The frontend should never have direct DB credentials.
2.2 Building the REST Adapter (Next.js)
We implement the complete NextAuth Adapter Interface using HTTP calls to our NestJS API Gateway. The adapter includes a utility function to handle date parsing from JSON responses:
// apps/client/src/lib/auth/api-adapter.ts
import axios from 'axios';
import { Adapter, AdapterAccount, AdapterSession, AdapterUser, VerificationToken } from 'next-auth/adapters';
const isDate = (value: unknown): value is string =>
typeof value === 'string' ? new Date(value).toString() !== 'Invalid Date' && !Number.isNaN(Date.parse(value)) : false;
function format<T>(obj: Record<string, unknown>): T {
return Object.entries(obj).reduce((result, [key, value]) => {
const newResult = result;
if (value === null) {
return newResult;
}
if (isDate(value)) {
newResult[key] = new Date(value);
} else {
newResult[key] = value;
}
return newResult;
}, {} as Record<string, unknown>) as T;
}
function AuthRestAdapter(): Adapter {
const client = axios.create({
baseURL: `${process.env.NEXT_PUBLIC_API_GATEWAY_URL}/adapter`,
headers: {
'Content-Type': 'application/json',
'x-auth-secret': process.env.NEXTAUTH_SECRET || ''
}
});
return {
createUser: async (user: Omit<AdapterUser, 'id'>) => {
const response = await client.post('/signup', user);
return format<AdapterUser>(response.data);
},
getUserByEmail: async (email: string) => {
const response = await client.get('/get-user-by-email', { params: { email } });
return response.data ? format<AdapterUser>(response.data) : response.data;
},
getUserByAccount: async ({
providerAccountId,
provider
}: Pick<AdapterAccount, 'provider' | 'providerAccountId'>) => {
const response = await client.get(`/get-user-by-account/${provider}/${providerAccountId}`);
return response.data ? format<AdapterUser>(response.data) : response.data;
},
getUser: async (id: string) => {
const response = await client.get(`/${id}`);
return response.data ? format<AdapterUser>(response.data) : response.data;
},
updateUser: async (user: Partial<AdapterUser> & Pick<AdapterUser, 'id'>) => {
const response = await client.patch('/', user);
return format<AdapterUser>(response.data);
},
deleteUser: async (userId: string) => {
const response = await client.delete(`/${userId}`);
return response.data ? format<AdapterUser>(response.data) : response.data;
},
linkAccount: async (account: AdapterAccount) => {
const response = await client.post('/link-account', account);
return response.data ? format<AdapterAccount>(response.data) : response.data;
},
unlinkAccount: async ({ providerAccountId, provider }: Pick<AdapterAccount, 'provider' | 'providerAccountId'>) => {
const response = await client.delete(`/unlink-account/${provider}/${providerAccountId}`);
return response.data ? format<AdapterAccount>(response.data) : response.data;
},
createSession: async (session: { sessionToken: string; userId: string; expires: Date }) => {
const response = await client.post('/create-session', session);
return response.data ? format<AdapterSession>(response.data) : response.data;
},
getSessionAndUser: async (sessionToken: string) => {
const response = await client.get(`/get-session/${sessionToken}`);
if (!response.data) {
return response.data;
}
const session = format<AdapterSession>(response.data.session);
const user = format<AdapterUser>(response.data.user);
return { session, user };
},
updateSession: async (session: Partial<AdapterSession> & Pick<AdapterSession, 'sessionToken'>) => {
const response = await client.patch('/update-session', session);
return response.data ? format<AdapterSession>(response.data) : response.data;
},
deleteSession: async (sessionToken: string) => {
const response = await client.delete(`/delete-session/${sessionToken}`);
return response.data ? format<AdapterSession>(response.data) : response.data;
},
createVerificationToken: async (verificationToken: VerificationToken) => {
const response = await client.post('/create-verification-token', verificationToken);
return response.data ? format<VerificationToken>(response.data) : response.data;
},
useVerificationToken: async (params: { identifier: string; token: string }) => {
const response = await client.patch('/use-verification-token', params);
return response.data ? format<VerificationToken>(response.data) : response.data;
}
};
}
export default AuthRestAdapter;
2.3 NextAuth Options Setup
// apps/client/src/lib/auth/auth-options.ts
import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import { JWT } from "next-auth/jwt";
import AuthRestAdapter from "../utils/apiAdapter";
async function refreshToken(token: JWT): Promise<JWT> {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_GATEWAY_URL}/auth/refresh`, {
method: "POST",
body: JSON.stringify({
username: token.user.email,
sub: token.user.name,
}),
headers: {
authorization: `Refresh ${token.backendTokens.refreshToken}`,
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("Failed to refresh token");
}
const response = await res.json();
return {
...token,
backendTokens: response,
};
} catch (error) {
console.error("Token refresh failed:", error);
return token;
}
}
const authOptions : NextAuthOptions = {
adapter: AuthRestAdapter(),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
}),
CredentialsProvider({
name: 'Credentials',
credentials: {
email: {
label: "Email",
type: "text"
},
password: {
label: "Password",
type: "password"
},
},
async authorize(credentials) {
if(!credentials?.email || !credentials?.password) {
return null
}
const { email, password } = credentials;
const res = await fetch( `${ process.env.NEXT_PUBLIC_API_GATEWAY_URL }/auth/login`, {
method: "POST",
body: JSON.stringify({
email,
password,
}),
headers: {
"Content-Type": "application/json",
},
});
if(res.status === 400) {
return null
}
const user = await res.json();
return user;
},
}),
],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60
},
callbacks: {
async jwt({token, user, account}: {token: any, user: any, account: any}) {
if (account?.type === 'oauth'){
const res = await fetch(`${process.env.NEXT_PUBLIC_API_GATEWAY_URL}/auth/oAuthlogin`, {
method: "POST",
body: JSON.stringify({
email: user.email,
}),
headers: {
"Content-Type": "application/json",
},
});
if(res.status === 401) {
return null
}
const oAuthUserData = await res.json();
return {...token, ...oAuthUserData};
}
if (user && account?.type === 'credentials') {
return {...token, ...user};
}
if (token?.backendTokens?.expiresIn && new Date().getTime() < token.backendTokens.expiresIn) {
return token;
}
return refreshToken(token);
},
async session({ token, session }: {token: any, session: any}) {
const newSession = { ...session };
newSession.user = token.user;
newSession.backendTokens = token.backendTokens;
return newSession;
},
},
pages: {
signIn: "/signin",
},
secret: process.env.NEXTAUTH_SECRET,
};
export default authOptions;
2.4 Type Extensions
// apps/client/src/types/next-auth.d.ts
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-unused-vars */
import NextAuth from "next-auth";
import { JWT } from "next-auth/jwt";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name: string;
background: string;
companyName: string;
about: string;
image: string;
country: string;
website: string;
region: string;
annualOperatingBudget: string;
industry: string;
foundedYear: string;
headquarter: string;
employeesNumber: string;
created_at: Date;
updated_at: Date;
};
backendTokens: {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
user: {
id: string;
email: string;
name: string;
background: string;
companyName: string;
about: string;
image: string;
country: string;
website: string;
region: string;
annualOperatingBudget: string;
industry: string;
foundedYear: string;
headquarter: string;
employeesNumber: string;
created_at: Date;
updated_at: Date;
};
backendTokens: {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
}
}
Part 3: Database Schema Design
A solid data model is the backbone of any authentication system. We'll define a Prisma schema that supports standard NextAuth requirements while being extensible enough for our microservices architecture.
3.1 Prisma Schema
The database schema extends NextAuth's standard tables with application-specific fields. Using Prisma ensures type safety across the entire stack.
generator client {
provider = "prisma-client-js"
output = "./generated/companyAuthClient"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("COMPANY_AUTH_DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
providerType String
providerId String
providerAccountId String
refreshToken String?
accessToken String?
accessTokenExpires DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@unique([providerId, providerAccountId])
}
model Session {
id String @id @default(cuid())
userId String
expires DateTime
sessionToken String @unique
accessToken String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
}
model VerificationRequest {
id String @id @default(cuid())
identifier String
token String @unique
expires DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([identifier, token])
}
Part 4: Backend Implementation
This is where the heavy lifting happens. We will implement the backend logic across the API Gateway and the Auth Microservice, setting up secure communication channels, JWT generation, and route guards to protect our API endpoints.
4.1 API Gateway: Adapter Controller
The backend exposes endpoints that mirror the NextAuth adapter interface:
// apps/api/src/auth/adapter/adapter.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
} from "@nestjs/common";
import { AdapterService } from "./adapter.service";
import { ApiTags } from "@nestjs/swagger";
@ApiTags("adapter")
@Controller("adapter")
export class AdapterController {
constructor(private readonly adapterService: AdapterService) {}
@Post("signup")
async createUser(@Body() user: any): Promise<any> {
const res = await this.adapterService.createUser(user);
return res;
}
@Get("get-user/:id")
async getUserById(@Param("id") id: string): Promise<any> {
const res = await this.adapterService.getUserById(id);
return res;
}
@Get("get-user-by-email/")
async getUserByEmail(@Query("email") email: string): Promise<any> {
const res = await this.adapterService.getUserByEmail(email);
return res;
}
@Get("get-user-by-account/:provider/:providerAccountId")
async getUserByAccount(
@Param("provider") provider: string,
@Param("providerAccountId") providerAccountId: string,
): Promise<any> {
const res = await this.adapterService.getUserByAccount(
provider,
providerAccountId,
);
return res;
}
@Post("update-user")
async updateUser(@Body() user: any): Promise<any> {
const res = await this.adapterService.updateUser(user);
return res;
}
@Delete("delete-user/:id")
async deleteUser(@Param("id") id: string): Promise<any> {
const res = await this.adapterService.deleteUser(id);
return res;
}
@Post("link-account")
async linkAccount(@Body() account: any): Promise<any> {
const res = await this.adapterService.linkAccount(account);
return res;
}
@Delete("unlink-account/:provider/:providerAccountId")
async unlinkAccount(
@Param("provider") provider: string,
@Param("providerAccountId") providerAccountId: string,
): Promise<any> {
const res = await this.adapterService.unlinkAccount(
provider,
providerAccountId,
);
return res;
}
}
4.2 API Gateway: Adapter Service
// apps/api/src/auth/adapter/adapter.service.ts
import { HttpException, Inject, Injectable } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { firstValueFrom } from "rxjs";
@Injectable()
export class AdapterService {
constructor(@Inject("AUTH") private authMicroservice: ClientProxy) {}
async createUser(user: any): Promise<any> {
try {
const pattern = { cmd: "createUser" };
const res = await firstValueFrom(
this.authMicroservice.send<string, any>(pattern.cmd, user)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async getUserById(id: string): Promise<any> {
try {
const pattern = { cmd: "getUserById" };
const res = await firstValueFrom(
this.authMicroservice.send<string, string>(pattern.cmd, id)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async getUserByEmail(email: string): Promise<any> {
try {
const pattern = { cmd: "getUserByEmail" };
const res = await firstValueFrom(
this.authMicroservice.send<string, string>(pattern.cmd, email)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async getUserByAccount(provider: string, providerAccountId: string): Promise<any> {
try {
const pattern = { cmd: "getUserByAccount" };
const payload = { provider, providerAccountId };
const res = await firstValueFrom(
this.authMicroservice.send<string, any>(pattern.cmd, payload)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async updateUser(user: any): Promise<any> {
try {
const pattern = { cmd: "updateUser" };
const res = await firstValueFrom(
this.authMicroservice.send<string, any>(pattern.cmd, user)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async deleteUser(id: string): Promise<any> {
try {
const pattern = { cmd: "deleteUser" };
const res = await firstValueFrom(
this.authMicroservice.send<string, string>(pattern.cmd, id)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async linkAccount(account: any): Promise<any> {
try {
const pattern = { cmd: "linkAccount" };
const res = await firstValueFrom(
this.authMicroservice.send<string, any>(pattern.cmd, account)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async unlinkAccount(provider: string, providerAccountId: string): Promise<any> {
try {
const pattern = { cmd: "unlinkAccount" };
const payload = { provider, providerAccountId };
const res = await firstValueFrom(
this.authMicroservice.send<string, any>(pattern.cmd, payload)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
}
4.3 Auth Microservice: Adapter Controller
// apps/auth/src/adapter/adapter.controller.ts
import { Controller } from "@nestjs/common";
import { MessagePattern, Payload } from "@nestjs/microservices";
import { AdapterService } from "./adapter.service";
@Controller()
export class AdapterController {
constructor(private readonly adapterService: AdapterService) {}
@MessagePattern("createUser")
async createUser(@Payload() user: any): Promise<any> {
const res = await this.adapterService.createUser(user);
return res;
}
@MessagePattern("getUserById")
async getUserById(@Payload() id: string): Promise<any> {
const res = await this.adapterService.getUserById(id);
return res;
}
@MessagePattern("getUserByEmail")
async getUserByEmail(@Payload() email: string): Promise<any> {
const res = await this.adapterService.getUserByEmail(email);
return res;
}
@MessagePattern("getUserByAccount")
async getUserByAccount(@Payload() provider: any): Promise<any> {
const res = await this.adapterService.getUserByAccount(
provider.provider,
provider.providerAccountId,
);
return res;
}
@MessagePattern("updateUser")
async updateUser(@Payload() user: any): Promise<any> {
const res = await this.adapterService.updateUser(user);
return res;
}
@MessagePattern("deleteUser")
async deleteUser(@Payload() id: string): Promise<any> {
const res = await this.adapterService.deleteUser(id);
return res;
}
@MessagePattern("linkAccount")
async linkAccount(@Payload() account: any): Promise<any> {
const res = await this.adapterService.linkAccount(account);
return res;
}
@MessagePattern("unlinkAccount")
async unlinkAccount(@Payload() param: any): Promise<any> {
const res = await this.adapterService.unlinkAccount(
param.provider,
param.providerAccountId,
);
return res;
}
}
4.4 Auth Microservice: Adapter Service
// apps/auth/src/adapter/adapter.service.ts
import { Injectable } from "@nestjs/common";
import { EEmailVerificationStatus } from "src/auth/dto/request-email-verification.dto";
import { PrismaService } from "src/persistence/prisma/prisma.service";
@Injectable()
export class AdapterService {
constructor(private prisma: PrismaService) {}
async createUser(user: any): Promise<any> {
const res = await this.prisma.user.create({
data: {
...user,
emailVerified: EEmailVerificationStatus.VERIFIED,
},
});
return res;
}
async getUserById(id: string) {
const res = await this.prisma.user.findUnique({
where: {
id: id,
},
});
return res;
}
async getUserByEmail(email: string) {
const res = await this.prisma.user.findUnique({
where: {
email: email,
},
});
return res;
}
async getUserByAccount(provider: string, providerAccountId: string) {
const account = await this.prisma.account.findUnique({
where: {
provider_providerAccountId: {
provider: provider,
providerAccountId: providerAccountId,
},
},
});
if (account) {
const res = await this.prisma.user.findUnique({
where: {
id: account.userId,
},
});
return res;
}
return null;
}
async updateUser(user: any): Promise<any> {
const res = await this.prisma.user.update({
where: {
id: user.id,
},
data: {
...user,
},
});
return res;
}
async deleteUser(id: string): Promise<any> {
const res = await this.prisma.user.delete({
where: {
id: id,
},
});
return res;
}
async linkAccount(account: any): Promise<any> {
const res = await this.prisma.account.create({
data: {
userId: account.userId,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
access_token: account.access_token,
expires_at: account.expires_at,
token_type: account.token_type,
id_token: account.id_token,
},
});
return res;
}
async unlinkAccount(
provider: string,
providerAccountId: string,
): Promise<any> {
const res = await this.prisma.account.delete({
where: {
provider_providerAccountId: {
provider: provider,
providerAccountId: providerAccountId,
},
},
});
return res;
}
}
4.5 JWT Auth: Auth Controller
// apps/api/src/auth/auth.controller.ts
import {
Body,
Controller,
Get,
HttpStatus,
Param,
Post,
Put,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { CreateAuthDto } from "./dto/create-auth.dto";
import { CreateRefreshDto } from "./dto/create-refresh.dto";
import { AuthService } from "./auth.service";
import { FileInterceptor } from "@nestjs/platform-express";
import { AzureBlobService } from "./azure-blob.service";
import { ApiTags } from "@nestjs/swagger";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { Response } from "express";
import { RequestEmailVerificationDto } from "./dto/request-email-verification.dto";
import { JwtGuard } from "src/guards/jwt.guard";
import { RefreshJwtGuard } from "src/guards/refresh-jwt.guard";
@ApiTags("auth")
@Controller("auth")
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly azureblobService: AzureBlobService,
) {}
@Post("register")
async register(@Body() createUserDto: CreateUserDto): Promise<any> {
const res = await this.authService.register(createUserDto);
return res;
}
@Post("login")
async login(@Body() createAuthDto: CreateAuthDto): Promise<any> {
const res = await this.authService.login(createAuthDto);
return res;
}
@Post("oAuthlogin")
async oAuthlogin(@Body() createAuthDto: CreateAuthDto): Promise<any> {
const res = await this.authService.oAuthlogin(createAuthDto.email);
return res;
}
@UseGuards(RefreshJwtGuard)
@Post("refresh")
async refreshToken(@Body() createRefreshDto: CreateRefreshDto): Promise<any> {
const refreshToken = await this.authService.refreshToken(createRefreshDto);
return refreshToken;
}
@Get(":id")
async findOneUser(@Param("id") id: string): Promise<any> {
const company = await this.authService.findOneUser(id);
return company;
}
@Put(":id")
async updateUser(
@Param("id") id: string,
@Body() updateUserDto: UpdateUserDto,
): Promise<any> {
const company = await this.authService.updateUser(id, updateUserDto);
return company;
}
@Put("upload-image/:id")
@UseInterceptors(FileInterceptor("image"))
async updateUserImage(
@Param("id") id: string,
@UploadedFile() image: Express.Multer.File,
): Promise<any> {
const containerName = process.env.AZURE_STORAGE_CONTAINER_NAME;
const imageUrl = await this.azureblobService.upload(image, containerName);
return { imageUrl };
}
@Get()
async findAllUsersWithId() {
const users = await this.authService.findAllUsersWithId();
return users;
}
@Post("forgot-password")
async forgotPassword(
@Body() forgotPasswordDto: ForgotPasswordDto,
@Res() response: Response,
): Promise<any> {
try {
await this.authService.forgotPassword(forgotPasswordDto);
return response.status(HttpStatus.OK).json({
message:
"If this user exists, they will receive an email for restoring their password",
});
} catch (error) {
return response.status(HttpStatus.BAD_REQUEST).json({
message:
"If this user exists, they will receive an email for restoring their password",
});
}
}
@Post("reset-password/:token")
async resetPassword(
@Param("token") token: string,
@Body() resetPasswordDto: { newPassword: string },
@Res() response: Response,
): Promise<any> {
try {
await this.authService.resetPassword(token, resetPasswordDto.newPassword);
return response.status(HttpStatus.OK).json({
message: "Password has been successfully reset",
});
} catch (error) {
return response.status(HttpStatus.BAD_REQUEST).json({
message: error.message || "Failed to reset password",
});
}
}
@UseGuards(JwtGuard)
@Post("request-email-verification")
async requestEmailVerification(
@Body() body: RequestEmailVerificationDto,
@Res() response: Response,
) {
try {
await this.authService.requestEmailVerification(body.userId, body.email);
return response.status(HttpStatus.OK).json({
message: "Email verification requested successfully",
});
} catch (error) {
return response.status(HttpStatus.BAD_REQUEST).json({
message: error.message || "Failed to request email verification",
});
}
}
@Post("verify-email/:token")
async verifyEmail(@Param("token") token: string, @Res() response: Response) {
try {
await this.authService.verifyEmail(token);
return response.status(HttpStatus.OK).json({
message: "Email has been successfully verified",
});
} catch (error) {
return response.status(HttpStatus.BAD_REQUEST).json({
message: error.message || "Failed to verify email",
});
}
}
}
4.6 JWT Auth: Auth Service
// apps/api/src/auth/auth.service.ts
import { HttpException, Inject, Injectable } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";
import { ClientProxy } from "@nestjs/microservices";
import { CreateAuthDto } from "./dto/create-auth.dto";
import { CreateRefreshDto } from "./dto/create-refresh.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { RequestEmailVerificationDto } from "./dto/request-email-verification.dto";
import { firstValueFrom } from "rxjs";
@Injectable()
export class AuthService {
constructor(@Inject("AUTH") private authMicroservice: ClientProxy) {}
async register(createUserDto: CreateUserDto): Promise<any> {
try {
const pattern = { cmd: "register" };
const res = await firstValueFrom(
this.authMicroservice.send<string, CreateUserDto>(pattern.cmd, createUserDto)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async login(createAuthDto: CreateAuthDto): Promise<any> {
try {
const pattern = { cmd: "login" };
const res = await firstValueFrom(
this.authMicroservice.send<string, CreateAuthDto>(pattern.cmd, createAuthDto)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async oAuthlogin(email: string): Promise<any> {
try {
const pattern = { cmd: "oAuthlogin" };
const res = await firstValueFrom(
this.authMicroservice.send<string, string>(pattern.cmd, email)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async refreshToken(createRefreshDto: CreateRefreshDto): Promise<any> {
try {
const pattern = { cmd: "refresh" };
const res = await firstValueFrom(
this.authMicroservice.send<string, CreateRefreshDto>(pattern.cmd, createRefreshDto)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async findOneUser(id: string): Promise<any> {
try {
const pattern = { cmd: "findOneUser" };
const res = await firstValueFrom(
this.authMicroservice.send<string, string>(pattern.cmd, id)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async updateUser(id: string, updateUserDto: UpdateUserDto) {
try {
const pattern = { cmd: "UpdateUser" };
const payload = { id, ...updateUserDto };
const res = await firstValueFrom(
this.authMicroservice.send<string, any>(pattern.cmd, payload)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async updateUserImage(id: string, imageUrl: string) {
try {
const pattern = { cmd: "updateUserImage" };
const payload = { id, imageUrl };
const res = await firstValueFrom(
this.authMicroservice.send<string, any>(pattern.cmd, payload)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async findAllUsersWithId() {
try {
const pattern = { cmd: "findAllUsersWithId" };
const res = await firstValueFrom(
this.authMicroservice.send<string, any>(pattern.cmd, {})
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async forgotPassword(forgotPasswordDto: ForgotPasswordDto): Promise<any> {
try {
const pattern = { cmd: "password.v1.forgot" };
const res = await firstValueFrom(
this.authMicroservice.send<string, ForgotPasswordDto>(pattern.cmd, forgotPasswordDto)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async resetPassword(token: string, newPassword: string): Promise<any> {
try {
const pattern = { cmd: "password.v1.reset" };
const payload = { token, newPassword };
const res = await firstValueFrom(
this.authMicroservice.send<string, any>(pattern.cmd, payload)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async requestEmailVerification(userId: string, email: string): Promise<any> {
try {
const pattern = { cmd: "email.v1.requestVerification" };
const payload = { userId, email };
const res = await firstValueFrom(
this.authMicroservice.send<string, RequestEmailVerificationDto>(pattern.cmd, payload)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
async verifyEmail(token: string): Promise<any> {
try {
const pattern = { cmd: "email.v1.verify" };
const res = await firstValueFrom(
this.authMicroservice.send<string, string>(pattern.cmd, token)
);
return res;
} catch (error) {
throw new HttpException(error, 400);
}
}
}
4.7 Auth Microservice: Core Controller
// apps/auth/src/auth/auth.controller.ts
import { Body, Controller } from "@nestjs/common";
import { MessagePattern, RpcException } from "@nestjs/microservices";
import { AuthService } from "./auth.service";
import { CreateAuthDto } from "./dto/create-auth.dto";
import { UserService } from "src/user/user.service";
import { CreateUserDto } from "src/user/dto/create-user.dto";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { RequestEmailVerificationDto } from "./dto/request-email-verification.dto";
@Controller()
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
@MessagePattern("register")
async registerUser(@Body() createUserDto: CreateUserDto) {
try {
const user = await this.userService.createUser(createUserDto);
if (!user) {
throw new RpcException("User creation failed");
}
const res = await this.authService.requestEmailVerification(
user.id,
user.email,
);
if (!res) {
throw new RpcException("Email verification request failed");
}
return user;
} catch (error) {
throw new RpcException(error);
}
}
@MessagePattern("login")
async loginUser(@Body() createAuthDto: CreateAuthDto): Promise<any> {
const res = await this.authService.login(createAuthDto);
return res;
}
@MessagePattern("oAuthlogin")
async oAuthlogin(@Body() email: string): Promise<any> {
const res = await this.authService.oAuthlogin(email);
return res;
}
@MessagePattern("refresh")
async refreshToken(@Body() user: any) {
const res = await this.authService.refreshToken(user);
return res;
}
@MessagePattern("password.v1.forgot")
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
try {
const results = await this.authService.forgotPassword(forgotPasswordDto);
return results;
} catch (error) {
throw new RpcException(error);
}
}
@MessagePattern("password.v1.reset")
async resetPassword(@Body() resetPasswordDto: any) {
try {
const results = await this.authService.resetPassword(
resetPasswordDto.token,
resetPasswordDto.newPassword,
);
return results;
} catch (error) {
throw new RpcException(error);
}
}
@MessagePattern("email.v1.requestVerification")
async requestEmailVerification(
@Body() requestEmailVerificationDto: RequestEmailVerificationDto,
): Promise<any> {
try {
const results = await this.authService.requestEmailVerification(
requestEmailVerificationDto.userId,
requestEmailVerificationDto.email,
);
return results;
} catch (error) {
throw new RpcException(error);
}
}
@MessagePattern("email.v1.verify")
async verifyEmail(@Body() token: string): Promise<any> {
try {
const results = await this.authService.verifyEmail(token);
return results;
} catch (error) {
throw new RpcException(error);
}
}
}
4.8 Auth Microservice: Core Service
// apps/auth/src/auth/auth.service.ts
import { Injectable } from "@nestjs/common";
import { CreateAuthDto } from "./dto/create-auth.dto";
import { compare } from "bcrypt";
import { JwtService } from "@nestjs/jwt";
import { RpcException } from "@nestjs/microservices";
import { UserService } from "src/user/user.service";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { MailService } from "src/mail/mail.service";
import { hash } from "bcrypt";
import { EEmailVerificationStatus } from "./dto/request-email-verification.dto";
const EXPIRE_TIME = 24 * 60 * 60 * 1000;
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private jwtService: JwtService,
private readonly mailService: MailService,
) {}
async login(createAuthDto: CreateAuthDto): Promise<any> {
const user = await this.userService.findCompanyByEmail(createAuthDto.email);
if (user && (await compare(createAuthDto.password, user.password))) {
const { password, ...safeUser } = user;
const payload = {
username: user.email,
emailVerified: user.emailVerified,
sub: {
name: user.name,
},
};
return {
user: safeUser,
backendTokens: {
accessToken: await this.jwtService.signAsync(payload, {
expiresIn: "1d",
secret: process.env.JWT_SECRET_KEY,
}),
refreshToken: await this.jwtService.signAsync(payload, {
expiresIn: "7d",
secret: process.env.JWT_REFRESH_TOKEN,
}),
expiresIn: new Date().setTime(new Date().getTime() + EXPIRE_TIME),
},
};
}
throw new RpcException("UnauthorizedException");
}
async oAuthlogin(email: string): Promise<any> {
const user = await this.userService.findCompanyByEmail(email);
if (user) {
const { password, ...safeUser } = user;
const payload = {
username: email,
emailVerified: user.emailVerified,
sub: {
name: user.name,
},
};
return {
user: safeUser,
backendTokens: {
accessToken: await this.jwtService.signAsync(payload, {
expiresIn: "1d",
secret: process.env.JWT_SECRET_KEY,
}),
refreshToken: await this.jwtService.signAsync(payload, {
expiresIn: "7d",
secret: process.env.JWT_REFRESH_TOKEN,
}),
},
};
}
throw new RpcException("UnauthorizedException");
}
async validateUser(createAuthDto: CreateAuthDto) {
const user = await this.userService.findCompanyByEmail(createAuthDto.email);
if (user && (await compare(createAuthDto.password, user.password))) {
return user;
}
throw new RpcException("UnauthorizedException");
}
async refreshToken(user: any) {
const payload = {
username: user.username,
sub: user.sub,
};
return {
accessToken: await this.jwtService.signAsync(payload, {
expiresIn: "1d",
secret: process.env.JWT_SECRET_KEY,
}),
refreshToken: await this.jwtService.signAsync(payload, {
expiresIn: "7d",
secret: process.env.JWT_REFRESH_TOKEN,
}),
expiresIn: new Date().setTime(new Date().getTime() + EXPIRE_TIME),
};
}
async forgotPassword(forgotPasswordDto: ForgotPasswordDto) {
const email = forgotPasswordDto.email;
try {
const user = await this.userService.findUserByEmail(email);
if (!user) {
return { message: "If an account exists, a password reset email has been sent" };
}
const expire = new Date();
expire.setHours(expire.getHours() + 1);
const token = this.jwtService.sign(
{ id: user.id, email: user.email },
{
secret: process.env.JWT_SECRET_KEY,
expiresIn: expire.getTime() - new Date().getTime(),
},
);
await this.userService.saveRestorePasswordToken({
userID: user.id,
token: token,
expires: expire,
isUsed: false,
});
await this.mailService.sendForgotPasswordEmail(user.email, token);
return { message: "If an account exists, a password reset email has been sent" };
} catch (error) {
return { message: "If an account exists, a password reset email has been sent" };
}
}
async resetPassword(token: string, newPassword: string): Promise<any> {
try {
const payload = this.jwtService.verify(token, {
secret: process.env.JWT_SECRET_KEY,
});
const user = await this.userService.findUSerByID(payload.id);
if (!user) {
throw new RpcException("User does not exist");
}
const storedToken = await this.userService.findRestorePasswordToken(
user.id,
);
if (!storedToken || token !== storedToken.token) {
throw new RpcException("Invalid token");
}
if (storedToken.expires < new Date() || storedToken.isUsed) {
throw new RpcException("Token expired or already used");
}
const hashedPassword = await hash(newPassword, 10);
await this.userService.updateUserOnly({
id: user.id,
password: hashedPassword,
});
await this.userService.updateRestorePasswordToken({
id: storedToken.id,
isUsed: true,
});
return { message: "Password has been reset successfully" };
} catch (error) {
throw new RpcException(error.message || "Failed to reset password");
}
}
async requestEmailVerification(userId: string, email: string): Promise<any> {
try {
const user = await this.userService.findUSerByID(userId);
if (!user) throw new RpcException("User does not exist");
if (user.email !== email)
throw new RpcException("Email does not match user");
if (
!user.emailVerified ||
user.emailVerified !== EEmailVerificationStatus.PENDING
)
throw new RpcException(
"Email already verified or pending verification",
);
const expire = new Date();
expire.setHours(expire.getHours() + 24);
const token = this.jwtService.sign(
{ id: user.id, email: user.email },
{
secret: process.env.JWT_SECRET_KEY,
expiresIn: expire.getTime() - new Date().getTime(),
},
);
await this.userService.createEmailVerificationToken({
userId: user.id,
token: token,
expires: expire,
});
const verificationUrl = `${process.env.DNAME}/verify-email?token=${token}`;
await this.mailService.sendEmailVerificationEmail(
user.email,
verificationUrl,
);
return { message: "Email verification requested successfully" };
} catch (error) {
throw new RpcException(
error.message || "Failed to request email verification",
);
}
}
async verifyEmail(token: string): Promise<any> {
try {
const payload = this.jwtService.verify(token, {
secret: process.env.JWT_SECRET_KEY,
});
const user = await this.userService.findUSerByID(payload.id);
if (!user) {
throw new RpcException("User does not exist");
}
const storedToken =
await this.userService.findEmailVerificationTokenByUserId(user.id);
if (!storedToken || token !== storedToken.token) {
throw new RpcException("Invalid token");
}
if (storedToken.expires < new Date()) {
throw new RpcException("Token expired");
}
await this.userService.updateUserOnly({
id: user.id,
emailVerified: EEmailVerificationStatus.VERIFIED,
});
await this.userService.deleteEmailVerificationToken(storedToken.id);
return { message: "Email successfully verified" };
} catch (error) {
throw new RpcException(error.message || "Failed to verify email");
}
}
}
4.9 API Guard: JWT Guard
// apps/api/src/auth/guards/jwt.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
@Injectable()
export class JwtGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: process.env.JWT_SECRET_KEY,
});
request["user"] = payload;
} catch (error) {
throw new UnauthorizedException();
}
return true;
}
private extractTokenFromHeader(request: Request) {
const authHeader = request.headers.authorization;
if (!authHeader) {
return undefined;
}
const [type, token] = authHeader.split(" ") ?? [];
return type === "Bearer" ? token : undefined;
}
}
4.10 API Guard: Refresh JWT Guard
// apps/api/src/auth/guards/refresh-jwt.guard.ts
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
const getJwtRefreshSecret = () => process.env.JWT_REFRESH_TOKEN;
@Injectable()
export class RefreshJwtGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: getJwtRefreshSecret(),
});
request["user"] = payload;
} catch {
throw new UnauthorizedException();
}
return true;
}
private extractTokenFromHeader(request: Request) {
const authHeader = request.headers.authorization;
if (!authHeader) {
return undefined;
}
const [type, token] = authHeader?.split(" ") ?? [];
return type === "Refresh" ? token : undefined;
}
}
Conclusion
By combining Next.js's user-centric session management with a specialized NestJS Auth Microservice, we've created a system that is both secure and scalable. This architecture ensures that sensitive credentials stay on the server, tokens are managed statelessly, and the frontend remains lightweight and focused on user experience. While the initial setup is more complex than a monolithic approach, the long-term benefits in separation of concerns and maintainability make it a worthy investment for production-grade applications.
Written by

Technical Lead and Full Stack Engineer leading a 5-engineer team at Fygurs (Paris, Remote) on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.