NestJS Authentication without Passport.

Jay McDoniel | Trilon Consulting
Jay McDoniel

So you're working on a NestJS project, and you come to the part that you need to write in some authentication logic. Nothing fancy, no OAuth use, just a local login and a JSON Web Token (JWT). The first thing you may think to do is reach for passport, but why?

Sure, NestJS has @nestjs/passport to integrate with it, but what is passport really doing for you under the hood? It's checking that req.body.username and req.body.password exist in the passport-local package, and the rest is up to you.

As for the passport-jwt library, it's verifying that the JWT is where you tell it to look (usually the Authorization header as a bearer token) and that it's valid according to the signature you've told passport about.

But have you ever thought to wonder, how much it would be to write it all yourself? Let's walk through all the steps and see how difficult (or easy) this really is. In this article, we'll be writing a sign up route, a sign in route, and a route that requires authentication to have a full proof of concept.

Requirements

To get started you'll need a shiny new NestJS application and the package manager of choice (I'll be using pnpm)

$ nest new auth-without-passport -p pnpm
$ cd auth-without-passport

Now we'll go ahead and install argon2, and @nestjs/jwt to take care of our password hashing and JWT management

$ pnpm i argon2 @nestjs/jwt

And we should be ready to start from here. For simplicity sake, we're going to forgo adding a database and just use memory storage for the users that sign up.

Keep in mind that every time you restart the server, you'll need to re-sign up because the memory is not persisted. It should go without saying, but do not do this in your production environment.

Building the Sign Up

The first thing that we're going to need to allow our users to do is to sign up for our server.

For this, we'll implement a simple local login using a username (email) and a password. The only restriction we'll add to our password is length, but you can add other restrictions as necessary, but the more characters used, the more entropy generated for the password, and the more secure it becomes.

Let's start off by creating our AuthModule, AuthService, and AuthController via the @nestjs/cli.

$ pnpm nest g mo auth
$ pnpm nest g s auth
$ pnpm nest g co auth

Note: The pnpm in the commands above ensures that we use the local installation of @nestjs/cli rather than the global, just to make sure we don't run into any unwanted surprises between versions.

Now we need to add the JwtModule from @nestjs/jwt to the AuthModule like so:

@Module({
imports: [JwtModule.register({ secret: '$up3r$3cr3t' })],
controllers: [AuthController],
providers: [AuthService],
exports: [JwtModule],
})
export class AuthModule {}

This will give us access to injecting JwtService in our AuthService and anywhere that imports AuthModule, which will be useful when we write the JwtGuard.

Next, let's define our SignupDto that will be sent to POST /auth/signup.

export class SignupDto {
username: string;
password: string;
confirmationPassword: string;
firstName: string;
lastName: string;
}

The first name and last name are mainly here for personalization purposes, and should be switched out for other values as your application requires.

Next, let's build the SignupPipe that will validate that the parameters passed in are correct for signup. We could use class-validator and the ValidationPipe for some of this, but we'd need a pipe anyways or a check in the service to guarantee that the password and confirmationPassword are the same value.

@Injectable()
export class SignupPipe implements PipeTransform {
transform(value: unknown, _metadata: ArgumentMetadata) {
const errors: string[] = [];
if (!this.valueHasPassAndConfPass(value)) {
throw new BadRequestException('Invalid Request Body');
}
// you'll probably want to add in your own business rules here as well
if (value.password.length < 12) {
errors.push('password should be at least 12 characters long');
}
if (value.password !== value.confirmationPassword) {
errors.push('password and confirmationPassword do not match');
}
if (errors.length) {
throw new BadRequestException(errors.join('\n'));
}
return value;
}
private valueHasPassAndConfPass(val: unknown): val is SignupDto {
return (
typeof val === 'object' &&
'password' in val &&
'confirmationPassword' in val
);
}
}

With the pipe written and the DTO set up, we're ready to create our controller with the signup route.

@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('signup')
signup(@Body() newUser: SignupDto) {
return this.authService.signup(newUser);
}
}

We'll add in a login route later as well.

Now let's look at the AuthService#signup method, where we'll check whether the user doesn't exist, and securely save the new user to our in memory storage. (Once again, we're just doing this for demonstration purposes. Ideally this would be a real database of course!)

interface User {
// Note: we're just putting this here, but ideally - this would be in it's own file - interfaces/user.ts
username: string;
password: string;
firstName: string;
lastName: string;
}
@Injectable()
export class AuthService {
private users: User[] = [];
constructor(private readonly jwtService: JwtService) {}
async signup(newUser: SignupDto): { accessToken: string } {
if (users.find(u => u.username === newUser.username)) {
throw new ConflictException(`User with username ${newUser.username already exists}`);
}
const user = {
username: newUser.username,
password: await argon2.hash(newUser.password),
firstName: newUser.firstName,
lastName: newUser.lastName,
};
this.users.push(user);
return { accessToken: this.jwtService.sign({ sub: user.username }) };
}
}

With our AuthService now in place, as soon as a user signs up, they get back a JWT and can immediately start using the API.

We've also made sure to hash the user's password so that if for whatever reason a malicious actor is able to get the private users array, or were to somehow access our database, we would not expose the password.

Remember: Always make sure to hash your passwords.

Building the Log In

Now that we have signup functionality, we should set up log in to allow users to leave our API and come back at a later time and not have to go through the same sign up process.

For this, all we should need is a simple endpoint that:

  • takes in the username and password
  • compares the password against the password hash
  • and either returns a 403 or returns a new access token.

Let's go ahead and modify our AuthService and break out some useful methods that we'll be re-using for the log in and create the login method.

interface User {
username: string;
password: string;
firstName: string;
lastName: string;
}
// Note: We have a lot of inline interfaces below just for brevity, ideally you should have these in separate interface files
@Injectable()
export class AuthService {
private users: User[] = [];
constructor(private readonly jwtService: JwtService) {}
findUser(username: string): User | undefined {
return this.users.find((u) => u.username === username);
}
createAccessToken(username: string): { accessToken: string } {
return { accessToken: this.jwtService.sign({ sub: username }) };
}
async signup(newUser: SignupDto): Promise<{ accessToken: string }> {
if (this.findUser(newUser.username)) {
throw new ConflictException(
`User with username ${newUser.username} already exists`
);
}
const user = {
username: newUser.username,
password: await argon2.hash(newUser.password),
firstName: newUser.firstName,
lastName: newUser.lastName,
};
this.users.push(user);
return this.createAccessToken(user.username);
}
async login(user: LoginDto): Promise<{ accessToken: string }> {
try {
const existingUser = this.findUser(user.username);
if (!user) {
throw new Error();
}
const passwordMatch = await argon2.verify(
existingUser.password,
user.password
);
if (!passwordMatch) {
throw new Error();
}
return this.createAccessToken(user.username);
} catch (e) {
throw new UnauthorizedException(
'Username or password may be incorrect. Please try again'
);
}
}
}

With the new login method we can now set up our LoginDto and our endpoint.

export class LoginDto {
username: string;
password: string;
}
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('signup')
async signup(@Body(SignupPipe) newUser: SignupDto) {
return this.authService.signup(newUser);
}
@Post('login')
async login(@Body() user: LoginDto) {
return this.authService.login(user);
}
}

Building the JwtGuard

Now that we have both sign up and log in functionality implemented without much hassle. The last thing we need to do is build a JwtGuard and we'll be all setup and running without passport.

The guard we create should:

  • get the access token from the Authorization header as a Bearer token
  • verify that the token is valid
  • and assign req.user to the payload of the token.
@Injectable()
export class JwtGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const request = this.getRequest<
IncomingMessage & { user?: Record<string, unknown> }
>(context); // you could use FastifyRequest here too
try {
const token = this.getToken(request);
const user = this.jwtService.verify(token);
request.user = user;
return true;
} catch (e) {
// return false or throw a specific error if desired
return false;
}
}
protected getRequest<T>(context: ExecutionContext): T {
return context.switchToHttp().getRequest();
}
protected getToken(request: {
headers: Record<string, string | string[]>;
}): string {
const authorization = request.headers['authorization'];
if (!authorization || Array.isArray(authorization)) {
throw new Error('Invalid Authorization Header');
}
const [_, token] = authorization.split(' ');
return token;
}
}

Now all that needs to happen for this JwtGuard to be able to be used in our application, is to add AuthModule to the imports array of any module.

For example, say the POST /foo/hello in the FooController and FooModule needs to be protected.

So we use @UseGuards(JwtGuard) on that endpoint. Our FooModule could look something like this, making sure to import our AuthModule into the imports Array - and we're all set!

@Module({
imports: [AuthModule],
controllers: [FooController],
providers: [FooService],
})
export class FooModule {}

Conclusion

Wrapping up, it's rather easy to set up a basic JWT authentication system using a local username and password for sign up and login without the need of passport, all while understanding how each part of the system is working.

Further improvements could be made by splitting out the user storage to a database, implement refresh tokens, adding roles to the users, and making the JwtGuard a global guard with a way to skip it if metadata if present, but those ideas will be left for another tutorial.


Learn NestJS - Official NestJS Courses 📚

Level-up your NestJS and Node.js ecosystem skills in these incremental workshop-style courses, from the NestJS Creator himself, and help support the NestJS framework! 🐈

🚀 The NestJS Fundamentals Course is now LIVE and 25% off for a limited time!

🎉 NEW - NestJS Course Extensions now live!
#NestJS
#NodeJS
#Authentication

Share this Post!

📬 Trilon Newsletter

Stay up to date with all the latest Articles & News!

More from the Trilon Blog .

Jay McDoniel | Trilon Consulting
Jay McDoniel

NestJS Metadata Deep Dive

In this article we'll be doing a deep-dive and learning about how NestJS uses Metadata internally for everything from dependency injection, to decorators we use everyday!

Read More
Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

NestJS v10 is now available

Today I am excited to announce the official release of Nest 10: A progressive Node.js framework for building efficient and enterprise-grade, server-side applications.

Read More
Manuel Carballido | Trilon Consulting
Manuel Carballido

Implementing data source agnostic services with NestJS

Learn how to implement data source logic in an agnostic way in yours NestJS applications.

Read More

What we do at Trilon .

At Trilon, our goal is to help elevate teams - giving them the push they need to truly succeed in today's ever-changing tech world.

Trilon - Consulting

Consulting .

Let us help take your Application to the next level - planning the next big steps, reviewing architecture, and brainstorming with the team to ensure you achieve your most ambitious goals!

Trilon - Development and Team Augmentation

Development .

Trilon can become part of your development process, making sure that you're building enterprise-grade, scalable applications with best-practices in mind, all while getting things done better and faster!

Trilon - Workshops on NestJS, Node, and other modern JavaScript topics

Workshops .

Have a Trilon team member come to YOU! Get your team up to speed with guided workshops on a huge variety of topics. Modern NodeJS (or NestJS) development, JavaScript frameworks, Reactive Programming, or anything in between! We've got you covered.

Trilon - Open-source contributors

Open-source .

We love open-source because we love giving back to the community! We help maintain & contribute to some of the largest open-source projects, and hope to always share our knowledge with the world!

Explore more

Write us a message .

Let's talk about how Trilon can help your next project get to the next level.

Rather send us an email? Write to:

hello@trilon.io
Š 2019-2023 Trilon.