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;
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");
}
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,
};
}
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);
}
};
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);
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);
}
};
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",
});
}
};
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);
}
};
};
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
);
The order is:
- Verify the access token
- Check the user’s role for the requested team
- Validate the request body
- 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
401for authentication failures and403for 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.