DEV Community

Cover image for 🔐 How to create an authentication system with JWT in a Node.js API
Micael Miranda Inácio
Micael Miranda Inácio

Posted on

🔐 How to create an authentication system with JWT in a Node.js API

A crucial part of almost every system is a secure authentication mechanism. In this post, we'll implement authentication in a Node.js API built with Fastify. The creation of the API and the first routes related to the user table have already been covered in my previous posts — Check it out here. The base code is available in this GitHub repository: Blog - by micaelmi.

Creating a Login Route

To start the authentication process, we need to create a login route where the user will obtain an access token to use in protected routes. Inside src/routes/users, create login.ts:

import bcrypt from "bcrypt";
import type { FastifyInstance } from "fastify";
import { ZodTypeProvider } from "fastify-type-provider-zod";
import jwt from "jsonwebtoken";
import z from "zod";
import { env } from "../../env";
import { ClientError } from "../../errors/client-error";
import { prisma } from "../../lib/prisma";

export async function login(app: FastifyInstance) {
  app.withTypeProvider<ZodTypeProvider>().post(
    "/users/login",
    {
      schema: {
        summary: "User Login",
        tags: ["users"],
        body: z.object({
          credential: z.string().min(4), // username or email
          password: z.string().min(8).max(32),
        }),
      },
    },
    async (request, reply) => {
      const { credential, password } = request.body;

      const user = await prisma.user.findFirst({
        where: {
          OR: [{ username: credential }, { email: credential }],
        },
      });

      if (!user) throw new ClientError("User does not exist");

      const passwordMatch = await bcrypt.compare(password, user.password);

      if (!passwordMatch) throw new ClientError("Password does not match");

      const secretJwtKey = env.JWT_SECRET_KEY;
      const expirationTime = "30d";

      const token = jwt.sign(
        {
          sub: user.id,
          name: user.name,
          username: user.username,
          type: user.userTypeId,
        },
        secretJwtKey,
        { expiresIn: expirationTime }
      );

      return reply.send({ token });
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  1. Search for a user in the database using the provided username or email.
  2. Compare the provided password with the stored hashed password using bcrypt.
  3. Create a JWT token containing relevant user data from the database.
  4. Send the generated token back to the user.

Now, register the route in server.ts:

app.register(login);
Enter fullscreen mode Exit fullscreen mode

Test it in Swagger UI. The expected response should look like this:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Enter fullscreen mode Exit fullscreen mode

You can inspect the token's payload on the JWT Website.

Creating a Middleware to Verify Token Validity

Now we need to create a middleware — a function that runs between the request and response cycle, processing requests before they reach the final handler. This middleware ensures that only authenticated users can access protected routes.


import { FastifyRequest, FastifyReply } from "fastify";
import jwt, { JwtPayload } from "jsonwebtoken";
import { env } from "../env";

const secretKey = env.JWT_SECRET_KEY;

export const verifyToken = async (
  request: FastifyRequest,
  reply: FastifyReply
) => {
  try {
    const authorizationHeader = request.headers.authorization;
    if (!authorizationHeader) {
      return reply.status(401).send({ error: "Token not provided" });
    }

    const token = authorizationHeader.split(" ")[1];
    const decoded = jwt.verify(token, secretKey);

    if (typeof decoded === "string") {
      return reply.status(401).send({ error: "Invalid token" });
    }

    request.user = decoded as JwtPayload & {
      sub: string;
      name: string;
      username: string;
      type: string;
      iat: number;
      exp: number;
    };
  } catch (err: any) {
    console.error("Error on token validation:", err.message);
    return reply.status(401).send({ error: "Invalid token" });
  }
};
Enter fullscreen mode Exit fullscreen mode

This function:

  • Ensures a token is provided.
  • Verifies the token's validity.
  • Stores the decoded user data in request.user for later use.

Applying the Middleware and Testing Route Protection

To enforce authentication, add a preHandler hook in server.ts:

app.register(async (app) => {
  app.addHook("preHandler", verifyToken);
  // Protected routes go here
});
Enter fullscreen mode Exit fullscreen mode

Now, test a protected route without a token. The response should be:

{
  "error": "Token not provided"
}
Enter fullscreen mode Exit fullscreen mode

To enable token authentication in Swagger UI, update fastifySwagger configuration, adding the following code right after the info object:

//...
info: {
  title: "Blog API",
  description: "API for my blog project.",
  version: "1.0.0",
}, // ⬇️⬇️⬇️
securityDefinitions: {
  BearerAuth: {
    type: "apiKey",
    name: "Authorization",
    in: "header",
    description: "Enter your JWT token in the format: Bearer <token>",
  },
},
security: [{ BearerAuth: [] }],
// ...
Enter fullscreen mode Exit fullscreen mode

A new "Authorize" button will appear. Click it and enter your token in the following format:

Bearer <your-token>
Enter fullscreen mode Exit fullscreen mode

Note: Don't forget to include "Bearer" before the token.

After authorization, protected routes will be accessible.

Conclusion

Now we have a simple but effective authentication system that allows public and protected routes in the API. If anything seems off, check out the project repository: Blog - by micaelmi.

Further improvements could include role-based access control (RBAC) to set different permissions for different users or another user-type validation process. But for now, we have a fully functional authentication system.

Top comments (0)