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 aBearer
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 Advanced Concepts Course now LIVE!
- NestJS Authentication / Authorization Course now LIVE!
- NestJS GraphQL Course (code-first & schema-first approaches) are now LIVE!
- NestJS Authentication / Authorization Course now LIVE!