How I Build Authentication in NestJS Apps
Authentication is one of those backend areas that looks simple at first and gets more serious very quickly. Logging in a user is not enough on its own. A real application usually needs registration, hashed passwords, token-based access, refresh logic, email verification, password reset flows, and protected routes that can trust the current user. In one of my NestJS applications, I implemented an auth flow that tries to cover those real needs while still keeping the code understandable and maintainable.
Start with clear user and token models
The auth flow becomes much easier to reason about when the database structure reflects the real responsibilities of the system. In my case, the user record stores identity and account state, while session and token records handle authentication lifecycle concerns separately.
The user entity contains the usual account fields like email, password hash, first name, last name, and email verification state. I keep password hashes only, never raw passwords, and I also track whether the email address has been verified and when that happened.
Besides the user itself, I also use a dedicated table for user sessions and another one for one-time auth tokens like email verification and password reset tokens. That separation helps because not every token in the system should behave the same way.
@Entity('users')export class User {@PrimaryGeneratedColumn('uuid')id: string;@Column({ type: 'varchar', length: 255, unique: true })email: string;@Column({ name: 'password_hash', type: 'text' })passwordHash: string;@Column({ name: 'first_name', type: 'varchar', length: 100 })firstName: string;@Column({ name: 'last_name', type: 'varchar', length: 100 })lastName: string;@Column({ name: 'is_email_verified', type: 'boolean', default: false })isEmailVerified: boolean;@Column({ name: 'email_verified_at', type: 'timestamptz', nullable: true })emailVerifiedAt: Date | null;}
@Entity('auth_tokens')export class AuthToken {@PrimaryGeneratedColumn('uuid')id: string;@Column({ name: 'user_id', type: 'uuid' })userId: string;@Column({ name: 'token_hash', type: 'text' })tokenHash: string;@Column({ type: 'varchar', length: 50 })type: AuthTokenType;@Column({ name: 'expires_at', type: 'timestamptz' })expiresAt: Date;@Column({ name: 'used_at', type: 'timestamptz', nullable: true })usedAt: Date | null;}
Registration should do more than create a user
When a user registers, I first normalize the email, check whether an account already exists, and hash the password with Argon2. Hashing is one of the most important steps in the entire flow, because password storage should never rely on plain text or reversible encryption.
After creating the user, I immediately generate an email verification token and send it through the mail service. That means a newly created account exists, but still cannot fully behave like a verified account until the email verification step is completed.
This approach keeps the initial registration flow simple while still supporting a more secure onboarding process.
async register(dto: RegisterDto) {const email = dto.email.toLowerCase().trim();const existingUser = await this.usersService.findByEmail(email);if (existingUser) {throw new BadRequestException('User with this email already exists');}const passwordHash = await argon2.hash(dto.password);const user = await this.usersService.create({email,passwordHash,firstName: dto.firstName.trim(),lastName: dto.lastName.trim(),isEmailVerified: false,});const rawVerificationToken = await this.createEmailVerificationToken(user.id);await this.sendVerificationEmail(user.email, rawVerificationToken);return this.createAuthSession(user.id,user.email,user.firstName,user.lastName,);}
Login should verify both credentials and account state
A good login flow is not only about checking whether the password is correct. It should also verify whether the account is in the right state to log in. In my implementation, that means checking that the user exists, that the email has already been verified, and that the password matches the stored hash.
That small extra check for email verification is important because it prevents the app from treating a new but unverified account as fully active.
async login(dto: LoginDto) {const email = dto.email.toLowerCase().trim();const user = await this.usersService.findByEmail(email);if (!user) {throw new UnauthorizedException('Invalid credentials');}if (!user.isEmailVerified) {throw new UnauthorizedException('Email is not verified');}const isPasswordValid = await argon2.verify(user.passwordHash,dto.password,);if (!isPasswordValid) {throw new UnauthorizedException('Invalid credentials');}return this.createAuthSession(user.id,user.email,user.firstName,user.lastName,);}
Use access tokens for API access and separate one-time tokens for account actions
One detail I like in this setup is that different token types are used for different purposes. The JWT access token is for authenticated API calls. The refresh token is for continuing a session without asking the user to log in again too often. And separate raw tokens are generated for flows like email verification and password reset.
That avoids mixing responsibilities. A password reset token should not behave like a session token, and a session token should not be reused for one-time security actions.
export enum AuthTokenType {EMAIL_VERIFICATION = 'EMAIL_VERIFICATION',PASSWORD_RESET = 'PASSWORD_RESET',}
Protect routes with JWT strategy and a current user decorator
Once the user is authenticated, protected endpoints should be able to trust a validated user object without repeating auth logic in every controller. In NestJS, Passport and JWT strategy make that part clean.
I use a JWT strategy that reads the access token from the Authorization header, verifies it using the configured secret, loads the user, validates the related session, and then returns a safe current-user object to the request context.
That allows protected endpoints to stay very small. The controller only declares the guard and receives the current user through a custom decorator.
@Injectable()export class JwtStrategy extends PassportStrategy(Strategy) {constructor(private readonly authService: AuthService,private readonly configService: ConfigService,) {super({jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),ignoreExpiration: false,secretOrKey: configService.get<string>('JWT_ACCESS_SECRET'),});}async validate(payload: { sub: string; email: string; sessionId: string }) {const user = await this.authService.validateUserById(payload.sub);if (!user) {throw new UnauthorizedException('User not found');}const session = await this.authService.validateSessionById(payload.sessionId,user.id,);if (!session) {throw new UnauthorizedException('Session is invalid');}return {id: user.id,email: user.email,firstName: user.firstName,lastName: user.lastName,isEmailVerified: user.isEmailVerified,sessionId: payload.sessionId,};}}
@ApiBearerAuth('access-token')@UseGuards(JwtAuthGuard)@Get('me')me(@CurrentUser() user: CurrentUserType) {return { user };}
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {const request = ctx.switchToHttp().getRequest();return request.user;},);
Email verification and password reset are part of auth, not optional extras
In many real products, authentication is not finished when login works. Users also need a secure way to verify their email and recover access if they forget their password.
For both flows, I generate a random raw token, hash it before storing it in the database, and give it an expiration time. When the user later clicks the verification or reset link, I compare the raw token with stored token hashes until I find a valid match that is not expired and not already used.
After a successful verification or password reset, I mark the token as used. That makes the flow one-time by design.
private async createEmailVerificationToken(userId: string) {const rawToken = this.generateRawToken();const tokenHash = await argon2.hash(rawToken);const authToken = this.authTokenRepo.create({userId,tokenHash,type: AuthTokenType.EMAIL_VERIFICATION,expiresAt: this.getEmailVerificationExpiryDate(),usedAt: null,});await this.authTokenRepo.save(authToken);return rawToken;}
async verifyEmail(rawToken: string) {const authToken = await this.findValidEmailVerificationToken(rawToken);if (!authToken) {throw new UnauthorizedException('Invalid or expired verification token');}if (authToken.user.isEmailVerified) {return {message: 'Email is already verified',};}await this.usersService.markEmailAsVerified(authToken.userId);authToken.usedAt = new Date();await this.authTokenRepo.save(authToken);return {message: 'Email verified successfully',};}
async resetPassword(token: string, newPassword: string) {const authToken = await this.findValidPasswordResetToken(token);if (!authToken) {throw new UnauthorizedException('Invalid or expired reset token');}const passwordHash = await argon2.hash(newPassword);await this.usersService.updatePassword(authToken.userId, passwordHash);await this.usersService.revokeAllSessionsForUser(authToken.userId);authToken.usedAt = new Date();await this.authTokenRepo.save(authToken);return {message: 'Password has been reset successfully',};}
Why this auth structure works well for real applications
What I like about this setup is that it stays practical. It covers the common real-world auth requirements without turning the codebase into a maze. Registration, login, protected routes, email verification, password reset, and session-aware JWT validation all have their own clear responsibilities.
The result is an auth layer that feels production-oriented, but still readable for future maintenance. And for me, that is always the goal: not just making authentication work, but making it understandable and safe to extend later.
Continue exploring
More articles and full stack work
If this article was useful, you can also explore more writing on backend architecture, database-driven applications, and full stack product development.