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

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<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:
- Verifies the JWT and attaches user data to req.user
- 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
andvalidateRequest
handle the input validation -
authorizeEventAction
middleware is part of theupdateEventHandler
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