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

2

🔐 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.

Heroku

Built for developers, by developers.

Whether you're building a simple prototype or a business-critical product, Heroku's fully-managed platform gives you the simplest path to delivering apps quickly — using the tools and languages you already love!

Learn More

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

👋 Kindness is contagious

Explore a trove of insights in this engaging article, celebrated within our welcoming DEV Community. Developers from every background are invited to join and enhance our shared wisdom.

A genuine "thank you" can truly uplift someone’s day. Feel free to express your gratitude in the comments below!

On DEV, our collective exchange of knowledge lightens the road ahead and strengthens our community bonds. Found something valuable here? A small thank you to the author can make a big difference.

Okay