DEV Community

Cover image for NestJS Authentication Tutorial - Part 2: JWT Authentication & Email Verification
Rosa
Rosa

Posted on • Edited on

NestJS Authentication Tutorial - Part 2: JWT Authentication & Email Verification

This is part 2 of a 2-part series on building production-ready authentication in NestJS. If you haven't read Part 1: Foundation & Setup, start there first - we'll be building on the foundation established in that part.

Okay, so you've got the foundation from Part 1. Now comes the fun part – actually making people log in and not letting random strangers mess with your app.

The Authentication Flow I Actually Built

After fighting with authentication for way too long, here's what I finally got working:

  • Sign up (but can't do anything until they verify email)
  • Get an email verification link (printed to console for now, because I'm not setting up SMTP in this tutorial)
  • Verify their email and automatically log in
  • Login normally after that
  • Update their password without breaking everything
  • Log out properly

The system uses HTTP-only cookies for the JWT tokens.

Setting Up the Data Structures

First, I need to define what the authentication inputs and outputs look like:

// src/auth/dto/signup.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, MinLength, IsString } from 'class-validator';

@InputType()
export class SignupInput {
  @Field()
  @IsString()
  @IsNotEmpty()
  name: string;

  @Field()
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @Field()
  @IsNotEmpty()
  @MinLength(8)
  password: string;

  @Field()
  @IsNotEmpty()
  @MinLength(8)
  passwordConfirm: string;
}

Enter fullscreen mode Exit fullscreen mode
// src/auth/dto/login.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty } from 'class-validator';

@InputType()
export class LoginInput {
  @Field()
  @IsEmail()
  @IsNotEmpty()
  email: string;

  @Field()
  @IsNotEmpty()
  password: string;
}

Enter fullscreen mode Exit fullscreen mode
// src/auth/dto/update-password.input.ts
import { IsString, MinLength, IsNotEmpty } from 'class-validator';
import { InputType, Field } from '@nestjs/graphql';

@InputType()
export class UpdatePasswordInput {
  @Field()
  @IsString()
  @IsNotEmpty({ message: 'Current password is required' })
  currentPassword: string;

  @Field()
  @IsString()
  @MinLength(8, { message: 'Password must be at least 8 characters long' })
  password: string;

  @Field()
  @IsString()
  @IsNotEmpty({ message: 'Password confirmation is required' })
  passwordConfirm: string;
}

Enter fullscreen mode Exit fullscreen mode

For the responses, I keep it simple:

// src/auth/dto/auth-response.dto.ts
import { Field, ObjectType } from '@nestjs/graphql';
import { User } from 'src/user/user.entity';

@ObjectType()
export class AuthResponse {
  @Field(() => User)
  user: User;
}

Enter fullscreen mode Exit fullscreen mode
// src/auth/dto/signup-response.dto.ts
import { Field, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class SignupResponse {
  @Field()
  message: string;
}

Enter fullscreen mode Exit fullscreen mode

Notice I'm not returning the JWT token in the GraphQL response? That's because it's going straight into an HTTP-only cookie where JavaScript can't touch it.

The Authentication Service

This is where I spent most of my time debugging weird edge cases. The AuthService handles the entire authentication flow:

// src/auth/auth.service.ts
import {
  Injectable,
  UnauthorizedException,
  BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SignupInput } from './dto/signup.input';
import { LoginInput } from './dto/login.input';
import { PasswordService } from './password.service';
import { UserService } from 'src/user/user.service';
import { User } from 'src/user/user.entity';
import { UpdatePasswordInput } from './dto/update-password.input';

interface VerificationTokenPayload {
  email: string;
  sub: string;
  iat?: number;
  exp?: number;
}

@Injectable()
export class AuthService {
  constructor(
    private usersService: UserService,
    private jwtService: JwtService,
    private passwordService: PasswordService,
  ) {}

  async signup(
    signupInput: SignupInput,
  ): Promise<{ message: string; verificationToken?: string }> {
    // Check passwords match - doing this first saves a database call
    if (signupInput.password !== signupInput.passwordConfirm) {
      throw new BadRequestException('Passwords do not match');
    }

    const existingUser = await this.usersService.findByEmail(signupInput.email);
    if (existingUser) {
      throw new BadRequestException('Email already in use');
    }

    // Give them 15 minutes to verify their email
    const expiresDate = new Date();
    expiresDate.setMinutes(expiresDate.getMinutes() + 15);

    const user = await this.usersService.create({
      name: signupInput.name,
      email: signupInput.email,
      password: signupInput.password,
      isEmailVerified: false,
      emailVerificationExpires: expiresDate,
    });

    // Generate verification token (short-lived)
    const verificationToken = this.generateVerificationToken(user);

    // In real life, you'd send this via email service
    // For now, just log it so you can test
    console.log(`Verification token for ${user.email}: ${verificationToken}`);

    return { message: 'Please check your email for verification link' };
  }

  async verifyEmail(token: string): Promise<{ token: string; user: User }> {
    try {
      const decoded = this.jwtService.verify<VerificationTokenPayload>(token);
      const user = await this.usersService.findById(decoded.sub);

      if (!user) {
        throw new UnauthorizedException('Invalid token');
      }

      // Check if already verified
      if (user.isEmailVerified) {
        throw new BadRequestException('Email already verified');
      }

      // Check if verification expired
      if (
        user.emailVerificationExpires &&
        user.emailVerificationExpires < new Date()
      ) {
        // Clean up the unverified user account
        await this.usersService.remove(user.id);
        throw new UnauthorizedException(
          'Verification link has expired. Please sign up again.',
        );
      }

      // Mark email as verified
      await this.usersService.updateEmailVerification({
        userId: user.id,
        isEmailVerified: true,
        emailVerificationExpires: null,
      });

      // Get updated user and generate access token
      const updatedUser = await this.usersService.findById(user.id);
      const accessToken = this.generateToken(updatedUser);

      return { token: accessToken, user: updatedUser };
    } catch {
      throw new UnauthorizedException('Invalid or expired verification token');
    }
  }

  async login(loginInput: LoginInput): Promise<{ token: string; user: User }> {
    const user = await this.usersService.findByEmail(loginInput.email);
    if (!user) {
      // Same error message whether email exists or not (prevents email enumeration)
      throw new UnauthorizedException('Invalid email or password');
    }

    // Verify password using PasswordService
    const isPasswordValid = await this.passwordService.verify(
      loginInput.password,
      user.password,
    );

    if (!isPasswordValid) {
      throw new UnauthorizedException('Invalid email or password');
    }

    // Don't let unverified users log in
    if (!user.isEmailVerified) {
      throw new UnauthorizedException(
        'Email not verified. Please verify your email before logging in.',
      );
    }

    // All good - generate token
    const token = this.generateToken(user);

    return { token, user };
  }

  async updatePassword(
    userId: string,
    updatePasswordInput: UpdatePasswordInput,
  ): Promise<{ token: string; user: User }> {
    const user = await this.usersService.findById(userId);

    // Check if current password is correct using PasswordService
    const isCurrentPasswordValid = await this.passwordService.verify(
      updatePasswordInput.currentPassword,
      user.password,
    );

    if (!isCurrentPasswordValid) {
      throw new BadRequestException('Current password is incorrect');
    }

    // Check if new passwords match
    if (updatePasswordInput.password !== updatePasswordInput.passwordConfirm) {
      throw new BadRequestException('Passwords do not match');
    }

    // Update password - hashing handled by UsersService
    await this.usersService.update(userId, {
      password: updatePasswordInput.password,
    });

    const updatedUser = await this.usersService.findById(userId);

    const token = this.generateToken(updatedUser);

    return { token, user: updatedUser };
  }

  private generateToken(user: User): string {
    const payload = { email: user.email, sub: user.id, roles: user.roles };
    return this.jwtService.sign(payload);
  }

  private generateVerificationToken(user: User): string {
    const payload = { email: user.email, sub: user.id };

    // Verification tokens are short-lived
    return this.jwtService.sign(payload, {
      expiresIn: '15m',
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

The key insight here is that I use two different token types: short-lived verification tokens and longer-lived access tokens.

Also notice how I clean up expired user registrations automatically. If someone signs up but doesn't verify their email in time, their account gets deleted when they try to use the expired token. This prevents the database from filling up with junk accounts.

Decorators

These decorators make the code way cleaner and easier to read:

// src/auth/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

Enter fullscreen mode Exit fullscreen mode
// src/auth/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from 'src/user/role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

Enter fullscreen mode Exit fullscreen mode
// src/auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Request } from 'express';
import { User } from 'src/user/user.entity';

interface GqlContext {
  req: Request;
}

export const CurrentUser = createParamDecorator(
  (data: unknown, context: ExecutionContext) => {
    const ctx = GqlExecutionContext.create(context);
    const gqlContext = ctx.getContext<GqlContext>();
    return gqlContext.req.user as User;
  },
);

Enter fullscreen mode Exit fullscreen mode

Now I can use @CurrentUser() to get the authenticated user in any resolver. Much cleaner than manually extracting it from the context every time.

Guards and Strategies

Here's where things get interesting. Guards are like bouncers for your app - they decide who gets in and who doesn't. But understanding how they work took me way too long, so let me break it down properly.

JWT Strategy (Getting Tokens from Cookies)

// src/auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';

import { TypedConfigService } from 'src/config/typed-config.service';
import { AuthConfig } from 'src/config/auth.config';
import { RequestWithCookies } from 'src/types/express';
import { UserService } from 'src/user/user.service';
import { Role } from 'src/user/role.enum';

interface JwtPayload {
  sub: string;
  email: string;
  roles?: Role[];
  iat?: number;
  exp?: number;
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly config: TypedConfigService,
    private readonly usersService: UserService,
  ) {
    const auth = config.get<AuthConfig>('auth');

    if (!auth) {
      throw new Error('Auth config not found');
    }

    const jwtFromRequest = (req: RequestWithCookies) => {
      if (req.cookies?.airbnbCloneJWT) {
        return req.cookies.airbnbCloneJWT;
      }
    };

    super({
      jwtFromRequest,
      ignoreExpiration: false,
      secretOrKey: auth.jwt.secret,
    });
  }

  async validate(payload: JwtPayload) {
    const user = await this.usersService.findById(payload.sub);
    if (!user) {
      throw new UnauthorizedException('User no longer exists');
    }
    return user;
  }
}

Enter fullscreen mode Exit fullscreen mode

The Main Auth Guard (Your App's Default Security)

// src/auth/guards/auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

interface GqlContext {
  req: Request;
}

@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    return super.canActivate(context);
  }

  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    const gqlContext = ctx.getContext<GqlContext>();
    return gqlContext.req;
  }
}

Enter fullscreen mode Exit fullscreen mode

Here's the important part that confused me for hours: This guard is applied globally (you'll see how in the module setup). That means every single GraphQL operation requires authentication by default.

Want a route to be public? You have to explicitly mark it with @Public(). It's way more secure - you can't accidentally forget to protect a route.

Role-Based Access Guard (For Admin Stuff)

// src/auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { Role } from 'src/user/role.enum';
import { GqlContext } from 'src/types/express';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles) {
      return true;
    }

    const ctx = GqlExecutionContext.create(context);
    const gqlContext = ctx.getContext<GqlContext>();

    const req = gqlContext.req;
    const user = req.user;

    if (!user) {
      return false;
    }

    return requiredRoles.some((role) => user.roles?.includes(role));
  }
}

Enter fullscreen mode Exit fullscreen mode

This guard is also applied globally, but it only kicks in when you use the @Roles() decorator. Here's how you'd use it:

@Roles(Role.ADMIN)
@Query(() => [User])
async getAllUsers(): Promise<User[]> {
// Only admins can access this
  return this.usersService.findAll();
}
Enter fullscreen mode Exit fullscreen mode

The guard checks if the user has at least one of the required roles. If they don't, they get rejected.

Email Verification Guard (For Verified Users Only)

// src/auth/guards/verified-email.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { GqlContext } from 'src/types/express';

@Injectable()
export class VerifiedEmailGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const ctx = GqlExecutionContext.create(context);
    const gqlContext = ctx.getContext<GqlContext>();

    const req = gqlContext.req;
    const user = req.user;

    if (!user) {
      return false;
    }

    if (!user.isEmailVerified) {
      throw new UnauthorizedException('Email not verified');
    }

    return true;
  }
}

Enter fullscreen mode Exit fullscreen mode

This one is not applied globally. You use it on specific routes where you want to make sure the user has verified their email:

@UseGuards(VerifiedEmailGuard)
@Mutation(() => AuthResponse)
async updatePassword(
  @CurrentUser() user: User,
  @Args('updatePasswordInput') updatePasswordInput: UpdatePasswordInput,
): Promise<AuthResponse> {
// Only verified users can change their password
}
Enter fullscreen mode Exit fullscreen mode

Cookie Management

Managing cookies securely is more complex than it should be:

// src/auth/utils/token.utils.ts
import { Injectable } from '@nestjs/common';
import { Response } from 'express';

import { ConfigService } from '@nestjs/config';
import { User } from 'src/user/user.entity';

@Injectable()
export class TokenUtils {
  constructor(private configService: ConfigService) {}

  setCookieToken(res: Response, token: string): void {
    const cookieExpiresInDays = parseInt(
      this.configService.get('JWT_COOKIE_EXPIRES_IN') || '7',
      10,
    );

    const cookieOptions = {
      expires: new Date(Date.now() + cookieExpiresInDays * 24 * 60 * 60 * 1000),
      httpOnly: true,
      secure: this.configService.get('NODE_ENV') === 'production',
      path: '/',
    };

    res.cookie('airbnbCloneJWT', token, cookieOptions);
  }

  createSendToken(
    user: User,
    statusCode: number,
    res: Response,
    token: string,
  ): any {
    this.setCookieToken(res, token);

    const userResponse = { ...user } as Partial<User>;
    delete userResponse.password;

    return {
      user: userResponse,
    };
  }
}

Enter fullscreen mode Exit fullscreen mode

The security options here are crucial:

  • httpOnly: Prevents XSS attacks by making the cookie inaccessible to JavaScript
  • secure: Only sends cookie over HTTPS in production
  • path: '/': Makes cookie available to all routes

The GraphQL Resolver (Where It All Comes Together)

Finally, the resolver that ties everything together:

// src/auth/auth.resolver.ts
import { Resolver, Mutation, Args, Context } from '@nestjs/graphql';
import { AuthService } from './auth.service';
import { AuthResponse } from './dto/auth-response.dto';
import { SignupInput } from './dto/signup.input';
import { LoginInput } from './dto/login.input';
import { SignupResponse } from './dto/signup-response.dto';
import { TokenUtils } from './utils/token.utils';
import { Public } from './decorators/public.decorator';
import { GqlContext } from 'src/types/express';
import { CurrentUser } from './decorators/current-user.decorator';
import { UseGuards } from '@nestjs/common';
import { User } from 'src/user/user.entity';
import { VerifiedEmailGuard } from './guards/verified-email.guard';
import { UpdatePasswordInput } from './dto/update-password.input';

@Resolver()
export class AuthResolver {
  constructor(
    private readonly authService: AuthService,
    private readonly tokenUtils: TokenUtils,
  ) {}

  @Public()
  @Mutation(() => SignupResponse)
  async signup(
    @Args('signupInput') signupInput: SignupInput,
  ): Promise<SignupResponse> {
    return this.authService.signup(signupInput);
  }

  @Public()
  @Mutation(() => AuthResponse)
  async verifyEmail(
    @Args('token') token: string,
    @Context() context: GqlContext,
  ): Promise<AuthResponse> {
    const { token: authToken, user } =
      await this.authService.verifyEmail(token);

    // Set the token in an HTTP-only cookie only
    this.tokenUtils.setCookieToken(context.res, authToken);

    return { user };
  }

  @Public()
  @Mutation(() => AuthResponse)
  async login(
    @Args('loginInput') loginInput: LoginInput,
    @Context() { res }: GqlContext,
  ): Promise<AuthResponse> {
    const { token, user } = await this.authService.login(loginInput);

    // Set the token in an HTTP-only cookie only
    this.tokenUtils.setCookieToken(res, token);

    return { user };
  }

  @Mutation(() => AuthResponse)
  @UseGuards(VerifiedEmailGuard)
  async updatePassword(
    @CurrentUser() user: User,
    @Args('updatePasswordInput') updatePasswordInput: UpdatePasswordInput,
    @Context() context: GqlContext,
  ): Promise<AuthResponse> {
    const result = await this.authService.updatePassword(
      user.id,
      updatePasswordInput,
    );

    // Set the new token in an HTTP-only cookie only
    this.tokenUtils.setCookieToken(context.res, result.token);

    return { user: result.user };
  }

  @Mutation(() => Boolean)
  // eslint-disable-next-line @typescript-eslint/require-await
  async logout(@Context() context: GqlContext): Promise<boolean> {
    // Clear the cookie with secure options
    context.res.clearCookie('airbnbCloneJWT', {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      path: '/',
    });
    return true;
  }
}

Enter fullscreen mode Exit fullscreen mode

Notice how the public mutations use @Public() decorator, while updatePassword requires a verified email with @UseGuards(VerifiedEmailGuard).

Also, I generate a new JWT after password updates since the user's credentials changed. This invalidates any other active sessions.

Wiring It All Together

Finally, the AuthModule that makes everything work:

// src/auth/auth.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthResolver } from './auth.resolver';
import { JwtStrategy } from './strategies/jwt.strategy';
import { PasswordService } from './password.service';
import { TypedConfigService } from 'src/config/typed-config.service';
import { AuthConfig } from 'src/config/auth.config';
import { RolesGuard } from './guards/roles.guard';
import { APP_GUARD } from '@nestjs/core';
import { GqlAuthGuard } from './guards/auth.guard';
import { VerifiedEmailGuard } from './guards/verified-email.guard';
import { TokenUtils } from './utils/token.utils';
import { UserModule } from 'src/user/user.module';

@Module({
  imports: [
    forwardRef(() => UserModule),
    PassportModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: TypedConfigService) => ({
        secret: config.get<AuthConfig>('auth')?.jwt.secret,
        signOptions: {
          expiresIn: config.get<AuthConfig>('auth')?.jwt.expiresIn,
        },
      }),
    }),
  ],
  providers: [
    AuthResolver,
    AuthService,
    JwtStrategy,
    PasswordService,
    RolesGuard,
    GqlAuthGuard,
    VerifiedEmailGuard,
    TokenUtils,
    {
      provide: APP_GUARD,
      useClass: GqlAuthGuard,
    },
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
    {
      provide: TypedConfigService,
      useExisting: ConfigService,
    },
  ],
  exports: [AuthService, PasswordService, TokenUtils],
})
export class AuthModule {}

Enter fullscreen mode Exit fullscreen mode

The APP_GUARD providers make authentication required by default for all GraphQL operations. Routes need to be explicitly marked as @Public() to bypass authentication.

Testing It Out

Start your app and try the flow:

  1. Signup: Creates an unverified user and prints a verification link
  2. Email Verification: Click the link (or paste token in GraphQL playground) to verify and auto-login
  3. Login: Regular login for verified users
  4. Protected Operations: Try accessing user data - should work once authenticated
  5. Logout: Clears the authentication cookie

The authentication state persists across browser sessions thanks to the secure HTTP-only cookies.

What I Learned

  • Cookie security matters
  • Error messages should be vague: Don't leak information about which emails exist in your system
  • Email verification cleanup is essential
  • GraphQL context is different: You need custom guards and extractors for GraphQL vs REST

Next Steps

This authentication system is production-ready, but there are some features you might want to add:

  • Email service integration (SendGrid, AWS SES, etc.)
  • Password reset functionality
  • Rate limiting for login attempts
  • Refresh tokens for longer sessions
  • Account lockout after failed attempts

The foundation we've built makes adding these features much easier. The modular structure means you can extend the authentication system without breaking existing functionality.

And honestly? Once you get through the initial setup pain, this system is pretty solid. The HTTP-only cookies, email verification, and role-based access control handle most real-world authentication requirements.

Get the complete source code on GitHub

Top comments (0)