DEV Community

Cover image for Express Authentication and Team-Scoped RBAC with JWTs
Terry W
Terry W

Posted on • Edited on

Express Authentication and Team-Scoped RBAC with JWTs

Securing an API involves two separate concerns:

  • Authentication: identifying the user making the request
  • Authorisation: deciding whether that user can perform an action

In this article, we’ll create an Express authentication flow with:

  • Registration and login
  • Short-lived access tokens
  • Revocable refresh sessions
  • JWT authentication middleware
  • Team-scoped role checks for protected routes

The examples use TypeScript, Express, jsonwebtoken, bcryptjs, and express-validator.

1. Auth Routes and Validation

Create a dedicated authentication router:

import { Router } from "express";
import { body, validationResult } from "express-validator";

import {
  login,
  logout,
  refreshToken,
  register,
} from "../controllers/auth-controller";

const authRouter = Router();

const validateRequest = (req, res, next) => {
  const errors = validationResult(req);

  if (!errors.isEmpty()) {
    return res.status(400).json({
      status: "error",
      errors: errors.array(),
    });
  }

  next();
};

const registerValidation = [
  body("username")
    .trim()
    .isLength({ min: 3, max: 30 }),

  body("email")
    .trim()
    .isEmail()
    .normalizeEmail(),

  body("password")
    .isString()
    .isLength({ min: 15 })
    .custom((password: string) => {
      if (Buffer.byteLength(password, "utf8") > 72) {
        throw new Error(
          "Password exceeds the maximum size supported by bcrypt"
        );
      }

      return true;
    }),

  body("isEventOrganiser").optional().isBoolean(),

  body("teamName")
    .optional()
    .custom((value, { req }) => {
      if (
        req.body.isEventOrganiser &&
        (!value || value.trim() === "")
      ) {
        throw new Error(
          "Team name is required for event organisers"
        );
      }

      return true;
    }),
];

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

authRouter.post(
  "/login",
  [
    body("username").trim().notEmpty(),
    body("password").isString().notEmpty(),
  ],
  validateRequest,
  login
);

authRouter.post("/refresh-token", refreshToken);
authRouter.post("/logout", logout);

export default authRouter;
Enter fullscreen mode Exit fullscreen mode

Do not call .trim() on passwords. Whitespace may be intentional, and changing it means hashing a different value from the one the user entered.

bcrypt also has a 72-byte input limit. For new applications, Argon2id is generally a stronger default.

2. Generating Tokens

JWT secrets should never fall back to public placeholder values.

import {
  createHash,
  randomUUID,
} from "node:crypto";
import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.JWT_SECRET;
const JWT_REFRESH_SECRET =
  process.env.JWT_REFRESH_SECRET;

if (!JWT_SECRET || !JWT_REFRESH_SECRET) {
  throw new Error("JWT secrets must be configured");
}

const ISSUER = "events-api";
const AUDIENCE = "events-client";

function hashToken(token: string) {
  return createHash("sha256")
    .update(token)
    .digest("hex");
}
Enter fullscreen mode Exit fullscreen mode

Use short-lived access tokens and longer-lived refresh tokens:

async function generateTokens(user: User) {
  const accessToken = jwt.sign(
    {
      username: user.username,
      email: user.email,
    },
    JWT_SECRET,
    {
      subject: String(user.id),
      expiresIn: "15m",
      algorithm: "HS256",
      issuer: ISSUER,
      audience: AUDIENCE,
    }
  );

  const tokenId = randomUUID();

  const refreshToken = jwt.sign(
    {},
    JWT_REFRESH_SECRET,
    {
      subject: String(user.id),
      jwtid: tokenId,
      expiresIn: "7d",
      algorithm: "HS256",
      issuer: ISSUER,
      audience: AUDIENCE,
    }
  );

  await createSession({
    userId: user.id,
    tokenId,
    tokenHash: hashToken(refreshToken),
    expiresAt: new Date(
      Date.now() + 7 * 24 * 60 * 60 * 1000
    ),
  });

  return {
    accessToken,
    refreshToken,
  };
}
Enter fullscreen mode Exit fullscreen mode

Store a hash of the refresh token rather than the raw value. If the session table is compromised, the stored hash cannot be used directly as a valid token.

When a refresh token is used, revoke the existing session and issue a new token pair. This is known as refresh-token rotation.

Revoking a refresh session does not immediately invalidate an access token that has already been issued. The access token remains valid until its short expiry unless the API also checks server-side session state on every request.

3. Registration

Registration should hash the password and create related records inside a transaction:

import bcrypt from "bcryptjs";

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

    const passwordHash = await bcrypt.hash(
      password,
      12
    );

    const result = await withTransaction(
      async (client) => {
        const user = await insertUser(
          client,
          username,
          email,
          passwordHash
        );

        let team = null;
        let teamMember = null;

        if (isEventOrganiser) {
          team = await insertTeam(
            client,
            teamName,
            teamDescription
          );

          teamMember = await insertTeamMember(
            client,
            user.id,
            team.id,
            "team_admin"
          );
        }

        return {
          user,
          team,
          teamMember,
        };
      }
    );

    const tokens = await generateTokens(
      result.user
    );

    return res.status(201).json({
      status: "success",
      data: {
        user: {
          id: result.user.id,
          username: result.user.username,
          email: result.user.email,
        },
        ...tokens,
        ...(result.team && {
          team: result.team,
        }),
      },
    });
  } catch (error) {
    next(error);
  }
};
Enter fullscreen mode Exit fullscreen mode

Allowing a user to become team_admin is safe only when the role applies to the new team they created. It must not grant global administrator access.

The database should also enforce unique usernames and emails:

ALTER TABLE users
ADD CONSTRAINT users_username_unique
UNIQUE (username);

ALTER TABLE users
ADD CONSTRAINT users_email_unique
UNIQUE (email);
Enter fullscreen mode Exit fullscreen mode

Application-level checks alone cannot prevent duplicate records during concurrent requests.

4. Login

Return the same error whether the username or password is wrong:

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

    const user = await findUserByUsername(
      username
    );

    if (!user) {
      return res.status(401).json({
        status: "error",
        msg: "Invalid username or password",
      });
    }

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

    if (!passwordIsValid) {
      return res.status(401).json({
        status: "error",
        msg: "Invalid username or password",
      });
    }

    const tokens = await generateTokens(user);

    return res.json({
      status: "success",
      data: {
        user: {
          id: user.id,
          username: user.username,
          email: user.email,
        },
        ...tokens,
      },
    });
  } catch (error) {
    next(error);
  }
};
Enter fullscreen mode Exit fullscreen mode

Different messages such as “username incorrect” and “password incorrect” allow attackers to determine which accounts exist.

Login routes should also use rate limiting and failed-attempt monitoring.

5. Authentication Middleware

The authentication middleware verifies the access token and identifies its subject:

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

    if (!authHeader?.startsWith("Bearer ")) {
      return res.status(401).json({
        status: "error",
        msg: "Authentication required",
      });
    }

    const token = authHeader.slice(
      "Bearer ".length
    );

    const decoded = jwt.verify(
      token,
      JWT_SECRET,
      {
        algorithms: ["HS256"],
        issuer: ISSUER,
        audience: AUDIENCE,
      }
    );

    const userId = Number(decoded.sub);

    if (!Number.isInteger(userId)) {
      return res.status(401).json({
        status: "error",
        msg: "Invalid access token",
      });
    }

    req.user = {
      id: userId,
      username: decoded.username,
      email: decoded.email,
    };

    next();
  } catch (error) {
    return res.status(401).json({
      status: "error",
      msg:
        error instanceof Error &&
        error.name === "TokenExpiredError"
          ? "Access token expired"
          : "Invalid access token",
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

Verifying a JWT identifies the user represented by the token. It does not confirm that their role is still current.

Roles should therefore be checked against the database.

6. Team-Scoped Authorisation

A user may have different roles across different teams, so authorization should include both the user ID and team ID:

type TeamRole =
  | "team_admin"
  | "event_manager"
  | "team_member";

export const authorizeTeamRoles = (
  allowedRoles: TeamRole[]
) => {
  return async (req, res, next) => {
    try {
      if (!req.user) {
        return res.status(401).json({
          status: "error",
          msg: "Authentication required",
        });
      }

      const teamId = Number(
        req.params.teamId
      );

      const membership =
        await selectTeamMemberByUserAndTeam(
          req.user.id,
          teamId
        );

      if (
        !membership ||
        !allowedRoles.includes(
          membership.role
        )
      ) {
        return res.status(403).json({
          status: "error",
          msg: "Insufficient permissions",
        });
      }

      req.teamMembership = membership;
      next();
    } catch (error) {
      next(error);
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Looking up a role using only the user ID is not enough in a multi-team application. A role held in one team must not grant access to another team’s resources.

7. Protecting Routes

Apply authentication and authorisation separately:

eventsRouter.post(
  "/teams/:teamId/events",
  authenticate,
  authorizeTeamRoles([
    "team_admin",
    "event_manager",
  ]),
  eventValidation,
  validateRequest,
  createEventHandler
);
Enter fullscreen mode Exit fullscreen mode

The order is:

  1. Verify the access token
  2. Check the user’s role for the requested team
  3. Validate the request body
  4. Perform the operation

For update and delete routes, load the event first, determine which team owns it, and then check the user’s membership in that team.

Conclusion

This structure allows an Express API to:

  • Hash passwords securely
  • Issue short-lived access tokens
  • Rotate and revoke refresh sessions
  • Avoid storing raw refresh tokens
  • Identify users through JWT middleware
  • Check current roles against the database
  • Scope permissions to the relevant team
  • Return 401 for authentication failures and 403 for insufficient permissions

Authentication identifies the user. Authorisation determines whether that user can perform an action on a specific resource.

Keeping those responsibilities separate makes the system easier to understand and reduces the risk of accidentally exposing protected operations.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.