Express Authentication + RBAC

Introduction Securing an API goes beyond simply checking credentials. You need to control who can do what, and ensure that tokens are both short-lived and revocable. In this article, we’ll cover: Defining concise routes for registration, login, token refresh and logout, complete with input validation. Building a controller to hash passwords, generate JSON Web Tokens (JWTs) and store refresh tokens in the database. Writing middleware to verify JWTs, attach user information to requests and enforce role checks. Applying those middleware functions to event-management routes so that only authorised users can create, update or delete events. All snippets come from a single Express project; adapt them as needed for your setup. 1. Auth Routes with Validation First, set up a dedicated router for authentication. We use express-validator to enforce input rules. import { Router } from "express"; import { body, validationResult } from "express-validator"; import * as authController from "../controllers/auth-controller"; const authRouter = Router(); const validateRequest: RequestHandler = ( req: Request, res: Response, next: NextFunction ): void => { const errors = validationResult(req); if (!errors.isEmpty()) { const formattedErrors = errors.array().map((error) => { if (error.type === "field") { return { field: error.path, message: error.msg, }; } return { message: error.msg, }; }); res.status(400).json({ status: "error", errors: formattedErrors, }); return; } next(); }; const registerValidation = [ body("username") .trim() .notEmpty() .withMessage("Username is required") .isLength({ min: 3, max: 30 }) .withMessage("Username must be between 3 and 30 characters"), body("email") .trim() .notEmpty() .withMessage("Email is required") .isEmail() .withMessage("Must be a valid email address"), body("password") .trim() .notEmpty() .withMessage("Password is required") .isLength({ min: 6 }) .withMessage("Password must be at least 6 characters"), // Optional fields for event organiser registration body("isEventOrganiser") .optional() .isBoolean() .withMessage("isEventOrganiser must be a boolean"), body("teamName") .optional() .custom((value, { req }) => { // Only require teamName if isEventOrganiser is true if ( req.body.isEventOrganiser === true && (!value || value.trim() === "") ) { throw new Error( "Team name is required when registering as an event organiser" ); } return true; }) // Only apply length validation if a non-empty value is provided .if((value) => value !== undefined && value !== null && value.trim() !== "") .isLength({ min: 3, max: 100 }) .withMessage("Team name must be between 3 and 100 characters"), body("teamDescription").optional(), ]; authRouter.post("/register", registerValidation, validateRequest, register); authRouter.post("/login", loginValidation, validateRequest, login); authRouter.post( "/refresh-token", refreshTokenValidation, validateRequest, refreshTokenHandler ); authRouter.post("/logout", logoutValidation, validateRequest, logout); export default authRouter; 2. Auth Controller: Hashing, Tokens and Sessions The auth controller handles: Password hashing with bcryptjs JWT creation (access and refresh tokens) Storing refresh tokens in the database for later revocation Token Generation import jwt from "jsonwebtoken"; import bcrypt from "bcryptjs"; const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || "your-refresh-secret-key"; const ACCESS_TOKEN_EXPIRY = process.env.ACCESS_TOKEN_EXPIRY || "1d"; const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || "7d"; const generateTokens = async (user: User): Promise => { // Get staff role if exists const teamMember = await selectTeamMemberByUserId(user.id as number); // Create payload with user data and role const payload = { id: user.id, username: user.username, email: user.email, role: teamMember ? teamMember.role : null, // Add a timestamp to make tokens unique timestamp: Date.now(), }; // Generate access token const accessToken = jwt.sign( payload, JWT_SECRET as jwt.Secret, { expiresIn: ACCESS_TOKEN_EXPIRY, algorithm: "HS256", } as jwt.SignOptions ); // Generate refresh token const refreshToken = jwt.sign( { id: user.id, // Add randomness to make refresh tokens unique timestamp: Date.now(), nonce: Math.random().toString(36).substring(2), }, JWT_REFRESH_SECRET as jwt.Secret, { expiresIn: REFRESH_TOKEN_EXPIRY, algorithm: "HS256", } as jwt.SignOptions ); // Calc

Apr 24, 2025 - 04:18
 0
Express Authentication + RBAC

Introduction

Securing an API goes beyond simply checking credentials. You need to control who can do what, and ensure that tokens are both short-lived and revocable. In this article, we’ll cover:

  1. Defining concise routes for registration, login, token refresh and logout, complete with input validation.
  2. Building a controller to hash passwords, generate JSON Web Tokens (JWTs) and store refresh tokens in the database.
  3. Writing middleware to verify JWTs, attach user information to requests and enforce role checks.
  4. Applying those middleware functions to event-management routes so that only authorised users can create, update or delete events.

All snippets come from a single Express project; adapt them as needed for your setup.

1. Auth Routes with Validation

First, set up a dedicated router for authentication. We use express-validator to enforce input rules.

import { Router } from "express";
import { body, validationResult } from "express-validator";
import * as authController from "../controllers/auth-controller";

const authRouter = Router();

const validateRequest: RequestHandler = (
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    const formattedErrors = errors.array().map((error) => {
      if (error.type === "field") {
        return {
          field: error.path,
          message: error.msg,
        };
      }
      return {
        message: error.msg,
      };
    });

    res.status(400).json({
      status: "error",
      errors: formattedErrors,
    });
    return;
  }
  next();
};

const registerValidation = [
  body("username")
    .trim()
    .notEmpty()
    .withMessage("Username is required")
    .isLength({ min: 3, max: 30 })
    .withMessage("Username must be between 3 and 30 characters"),

  body("email")
    .trim()
    .notEmpty()
    .withMessage("Email is required")
    .isEmail()
    .withMessage("Must be a valid email address"),

  body("password")
    .trim()
    .notEmpty()
    .withMessage("Password is required")
    .isLength({ min: 6 })
    .withMessage("Password must be at least 6 characters"),

  // Optional fields for event organiser registration
  body("isEventOrganiser")
    .optional()
    .isBoolean()
    .withMessage("isEventOrganiser must be a boolean"),

  body("teamName")
    .optional()
    .custom((value, { req }) => {
      // Only require teamName if isEventOrganiser is true
      if (
        req.body.isEventOrganiser === true &&
        (!value || value.trim() === "")
      ) {
        throw new Error(
          "Team name is required when registering as an event organiser"
        );
      }
      return true;
    })
    // Only apply length validation if a non-empty value is provided
    .if((value) => value !== undefined && value !== null && value.trim() !== "")
    .isLength({ min: 3, max: 100 })
    .withMessage("Team name must be between 3 and 100 characters"),

  body("teamDescription").optional(),
];

authRouter.post("/register", registerValidation, validateRequest, register);

authRouter.post("/login", loginValidation, validateRequest, login);

authRouter.post(
  "/refresh-token",
  refreshTokenValidation,
  validateRequest,
  refreshTokenHandler
);

authRouter.post("/logout", logoutValidation, validateRequest, logout);

export default authRouter;

2. Auth Controller: Hashing, Tokens and Sessions

The auth controller handles:

  • Password hashing with bcryptjs
  • JWT creation (access and refresh tokens)
  • Storing refresh tokens in the database for later revocation

Token Generation

import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";

const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
const JWT_REFRESH_SECRET =
  process.env.JWT_REFRESH_SECRET || "your-refresh-secret-key";
const ACCESS_TOKEN_EXPIRY = process.env.ACCESS_TOKEN_EXPIRY || "1d";
const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || "7d";

const generateTokens = async (user: User): Promise<AuthTokens> => {
  // Get staff role if exists
  const teamMember = await selectTeamMemberByUserId(user.id as number);

  // Create payload with user data and role
  const payload = {
    id: user.id,
    username: user.username,
    email: user.email,
    role: teamMember ? teamMember.role : null,
    // Add a timestamp to make tokens unique
    timestamp: Date.now(),
  };

  // Generate access token
  const accessToken = jwt.sign(
    payload,
    JWT_SECRET as jwt.Secret,
    {
      expiresIn: ACCESS_TOKEN_EXPIRY,
      algorithm: "HS256",
    } as jwt.SignOptions
  );

  // Generate refresh token
  const refreshToken = jwt.sign(
    {
      id: user.id,
      // Add randomness to make refresh tokens unique
      timestamp: Date.now(),
      nonce: Math.random().toString(36).substring(2),
    },
    JWT_REFRESH_SECRET as jwt.Secret,
    {
      expiresIn: REFRESH_TOKEN_EXPIRY,
      algorithm: "HS256",
    } as jwt.SignOptions
  );

  // Calculate expiry date for the refresh token
  let refreshExpirySeconds: number;
  if (typeof REFRESH_TOKEN_EXPIRY === "string") {
    // Parse string like "7d" to seconds
    if (REFRESH_TOKEN_EXPIRY.endsWith("d")) {
      // Convert days to seconds
      refreshExpirySeconds = parseInt(REFRESH_TOKEN_EXPIRY) * 24 * 60 * 60;
    } else if (REFRESH_TOKEN_EXPIRY.endsWith("h")) {
      // Convert hours to seconds
      refreshExpirySeconds = parseInt(REFRESH_TOKEN_EXPIRY) * 60 * 60;
    } else if (REFRESH_TOKEN_EXPIRY.endsWith("m")) {
      // Convert minutes to seconds
      refreshExpirySeconds = parseInt(REFRESH_TOKEN_EXPIRY) * 60;
    } else {
      // Assume seconds or use default
      refreshExpirySeconds = parseInt(REFRESH_TOKEN_EXPIRY) || 60 * 60 * 24 * 7; // Default 7 days
    }
  } else {
    refreshExpirySeconds = 60 * 60 * 24 * 7; // Default 7 days
  }

  const expiresAt = new Date();
  expiresAt.setSeconds(expiresAt.getSeconds() + refreshExpirySeconds);

  // Store refresh token in database
  await createSession(user.id as number, accessToken, refreshToken, expiresAt);

  return { accessToken, refreshToken };
};

Register and Login

On registration, check for an existing username or email, hash the password and then wrap the creation and optional team setup in a transaction:

export const register = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const userData: RegistrationData = req.body;
    const {
      username,
      email,
      password,
      isEventOrganiser,
      teamName,
      teamDescription,
    } = userData;

    // Check if username or email already exists
    try {
      const existingUsername = await selectUserByUsername(username);
      // If we get here, the username exists
      return res.status(400).send({
        status: "error",
        msg: "Username already exists",
      });
    } catch (usernameError: any) {
      // If the error is a 404, it means the username doesn't exist - this is what we want
      if (usernameError.status !== 404) {
        return next(usernameError);
      }
    }

    try {
      const existingEmail = await selectUserByEmail(email);
      // If we get here, the email exists
      return res.status(400).send({
        status: "error",
        msg: "Email already exists",
      });
    } catch (emailError: any) {
      // If the error is a 404, it means the email doesn't exist - this is what we want
      if (emailError.status !== 404) {
        return next(emailError);
      }
    }

    // Hash password
    const saltRounds = 10;
    const passwordHash = await bcryptjs.hash(password, saltRounds);

    // Use transaction to ensure all operations succeed or fail together
    const result = await withTransaction(async () => {
      // Create user
      const newUser = await insertUser(username, email, passwordHash);
      const dbUser = newUser as User;

      let team: TeamResponse | null = null;
      let teamMember: ExtendedTeamMember | null = null;

      // If user wants to be an event organiser, create a team and add them as an event_manager
      if (isEventOrganiser) {
        if (!teamName) {
          throw {
            status: 400,
            msg: "Team name is required for event organisers",
          };
        }

        // Create team
        team = (await insertTeam(
          teamName,
          teamDescription
        )) as unknown as TeamResponse;

        // Add user as team event_manager
        teamMember = (await insertTeamMember(
          dbUser.id as number,
          team.id,
          "team_admin"
        )) as unknown as ExtendedTeamMember;
      }

      // Generate tokens
      const { accessToken, refreshToken } = await generateTokens(dbUser);

      // Sanitize user object for response (remove password_hash)
      const { password_hash, ...sanitizedUser } = dbUser;

      return { sanitizedUser, accessToken, refreshToken, team, teamMember };
    });

    res.status(201).send({
      status: "success",
      data: {
        user: {
          id: result.sanitizedUser.id,
          username: result.sanitizedUser.username,
          email: result.sanitizedUser.email,
        },
        accessToken: result.accessToken,
        refreshToken: result.refreshToken,
        ...(result.team && { team: result.team }),
        ...(result.teamMember && { teamMember: result.teamMember }),
      },
    });
  } catch (error) {
    next(error);
  }
};

The login endpoint verifies the password before issuing new tokens:

export const login = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const { username, password } = req.body;

    // Find user by username
    try {
      const user = await selectUserByUsername(username);

      // Ensure user is not null before proceeding
      if (!user) {
        return res.status(401).send({
          status: "error",
          msg: "Username is incorrect",
        });
      }

      // Verify password
      const isPasswordValid = await bcryptjs.compare(
        password,
        user.password_hash
      );
      if (!isPasswordValid) {
        return res.status(401).send({
          status: "error",
          msg: "Password is incorrect",
        });
      }

      // The user object from database will have an ID
      const dbUser = user as User;

      // Generate tokens
      const { accessToken, refreshToken } = await generateTokens(dbUser);

      // Sanitize user object for response (remove password_hash)
      const { password_hash, ...sanitizedUser } = dbUser;

      res.status(200).send({
        status: "success",
        data: {
          user: {
            id: sanitizedUser.id,
            username: sanitizedUser.username,
            email: sanitizedUser.email,
          },
          accessToken,
          refreshToken,
        },
      });
    } catch (error: any) {
      // Handle the 404 user not found error
      if (error.status === 404) {
        return res.status(401).send({
          status: "error",
          msg: "Username is incorrect",
        });
      }
      next(error);
    }
  } catch (error) {
    next(error);
  }
};

Authentication & Authorisation Middleware

To protect routes we need middleware that:

  1. Verifies the JWT and attaches user data to req.user
  2. Checks that the user has the required role, if any

Verifying the token

export const authenticate = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith("Bearer ")) {
      return res.status(401).json({
        status: "error",
        msg: "Unauthorized - No token provided",
      });
    }

    const token = authHeader.split(" ")[1];

    const decoded = jwt.verify(token, JWT_SECRET) as {
      id: number;
      username: string;
      email: string;
      role: string | null;
    };

    // Add user data to request
    req.user = decoded;

    next();
  } catch (error) {
    if (error instanceof Error && error.name === "TokenExpiredError") {
      // Get token anyway to provide expiry info
      const token = req.headers.authorization?.split(" ")[1] || "";

      return res.status(401).json({
        status: "error",
        msg: "Unauthorized - Token expired",
        details: "Please refresh your access token or log in again",
        tokenInfo: getTokenExpiryInfo(token),
      });
    }

    return res.status(401).json({
      status: "error",
      msg: "Unauthorized - Invalid token",
    });
  }
};

Checking Roles

export const authorize = (requiredRole: string) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      if (!req.user) {
        return res.status(401).json({
          status: "error",
          msg: "Unauthorized - Authentication required",
        });
      }

      // If no specific role is required, just being authenticated is enough
      if (!requiredRole) {
        return next();
      }

      // If user doesn't have a role yet, deny access
      if (!req.user.role) {
        return res.status(403).json({
          status: "error",
          msg: "Forbidden - Insufficient permissions",
        });
      }

      // Check if user has the required role
      const teamMember = await selectTeamMemberByUserId(req.user.id);

      if (!teamMember || teamMember.role !== requiredRole) {
        return res.status(403).json({
          status: "error",
          msg: "Forbidden - Insufficient permissions",
        });
      }

      next();
    } catch (error) {
      next(error);
    }
  };
};

You can extend this pattern for team or event specific checks by querying your database within the middleware.

4. Applying Middleware to Routes

Finally, protect any route by adding the appropriate middleware. For example, in the events router I added the auth middleware to the route handlers:

// POST /api/events - Create a new event
// Requires team admin or event_manager role
eventsRouter.post(
  "/",
  authenticateHandler,
  eventValidation,
  validateRequest,
  createEventHandler
);

The validateRequest function I used:

const validateRequest = (
  req: Request,
  res: Response,
  next: NextFunction
): void => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    // Check if there's a specific time validation error
    const timeError = errors
      .array()
      .find(
        (error) =>
          error.type === "field" &&
          error.msg === "End time must be after start time"
      );

    if (timeError) {
      // For PATCH requests, return the error as a msg
      if (req.method === "PATCH") {
        res.status(400).json({
          status: "error",
          msg: "End time must be after start time",
        });
        return;
      }
      // For POST requests, format the error to match the test expectations
      else {
        res.status(400).json({
          status: "error",
          errors: [
            {
              message: "End time must be after start time",
            },
          ],
        });
        return;
      }
    }

    // Handle other validation errors
    const formattedErrors = errors.array().map((error) => {
      if (error.type === "field") {
        return {
          field: error.path,
          message: error.msg,
        };
      }
      return {
        message: error.msg,
      };
    });

    res.status(400).json({
      status: "error",
      errors: formattedErrors,
    });
    return;
  }
  next();
};

Here:

  • authenticateHandler ensures users ID and JWT is valid
  • eventUpdateValidation and validateRequest handle the input validation
  • authorizeEventAction middleware is part of the updateEventHandler logic.

Conclusion

With a central token generation function and a handful of middleware routines, you can:

  • Confirm every request originates from a known user
  • Grant or deny access by a role
  • Refresh and revoke tokens through a session table

If you found this guide helpful, please give it a ❤️ and leave a comment below – I’d love to hear your feedback and any questions you have. And if you’d like hands-on help securing your API or building out your authentication workflows, feel free to message me on LinkedIn