Authentication is one of the most critical yet often misunderstood aspects of backend development. Due to its complexity, developers frequently turn to third-party solutions like Auth0 or Supabase. While these are excellent tools, building your own authentication system can provide greater flexibility and control.
In this guide, you'll learn how to implement a simple authentication middleware for your Express.js API service with minimal dependencies. By the end, you'll have:
- Fully functioning username + password authentication.
- Integration with PostgreSQL to store user accounts.
- A JWT-based authentication middleware.
- Refresh tokens with Automatic Reuse Detection for enhanced security.
This guide focuses on simplicity and avoids using packages like passport.js
to reduce complexity.
Setting Up the User Accounts Table
First, create a PostgreSQL table to store user accounts:
CREATE TABLE users (
"id" SERIAL PRIMARY KEY,
"username" VARCHAR(255) UNIQUE NOT NULL,
"password" VARCHAR(255) NOT NULL,
"email" VARCHAR(255) UNIQUE,
"created_at" TIMESTAMP NOT NULL DEFAULT NOW()
);
JWT Authentication Middleware
Next, create a JWT authentication middleware to protect API endpoints. This example uses symmetric encryption. For microservices architectures, consider using asymmetric encryption with a public/private key pair.
Middleware Code (/src/middleware/jwt.ts
):
import jwt from "jsonwebtoken";
const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY as string; // Randomly generated. Min length: 64 characters
export const protectedRoute: RequestHandler = async (req, _, next) => {
const authHeader = req.header("authorization");
if (!authHeader) {
return next(notAuthenticated());
}
const accessToken = authHeader.replace(new RegExp("\\b[Bb]earer\\s"), "");
try {
const { userId } = validateJWT(accessToken);
const user = await userRepository.getUserById(parseInt(userId));
if (user) {
req.user = user;
next();
} else {
next(invalidAccessToken());
}
} catch (err) {
next(invalidAccessToken());
}
};
const validateJWT = (token: string, verifyOptions?: jwt.VerifyOptions) => {
const jwtVerifyOptions = Object.assign(
{ algorithms: "HS256" },
verifyOptions,
{
issuer: "yourAPI.com",
audience: "yourAPI.com:client",
}
);
return jwt.verify(token, JWT_SECRET_KEY, jwtVerifyOptions) as T;
};
Use the middleware to secure routes:
import { protectedRoute } from "@/middleware/jwt";
router.get("/user", protectedRoute, async (req, res, next) => {
const user = req.user!;
res.json({ user });
});
Creating Authentication Controllers
Now, implement controllers for signup and login:
Signup Controller:
import argon from "argon2";
const signup = async (props) => {
const { username, password, email } = props;
await userRepo.getUser(username).then((res) => {
if (res !== null) throw usernameNotAvailable();
});
const hashedPass = await argon.hash(password, {
timeCost: 2,
parallelism: 1,
memoryCost: 19456,
});
const newUser = await createUser({
username,
hashedPass,
email,
});
const refreshToken = await generateRefreshToken(newUser.userId);
const accessToken = generateAccessToken(newUser.userId);
const { password: _, ...userRes } = newUser;
return { user: userRes, accessToken, refreshToken };
};
Login Controller:
const login = async (props) => {
const { username, password } = props;
const user = await getUser(username).then((res) => {
if (res === null) throw invalidLoginCredentials();
return res;
});
const isOk = await argon.verify(user.password, password);
if (isOk) {
const refreshToken = await generateRefreshToken(user.userId);
const accessToken = generateAccessToken(user.userId);
const { password: _, ...userRes } = user;
return { user: userRes, accessToken, refreshToken };
}
throw invalidLoginCredentials();
};
Storing Refresh Tokens
Refresh tokens provide long-term authentication. Let’s create a database table to store them:
CREATE TABLE refresh_tokens (
"id" SERIAL PRIMARY KEY,
"token" UUID NOT NULL DEFAULT gen_random_uuid(),
"token_family" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"active" BOOLEAN DEFAULT true,
"expires_at" TIMESTAMP NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT NOW()
);
Token Generators:
import jwt from "jsonwebtoken";
const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY as string; // Randomly generated. Min length: 64 characters
const generateAccessToken = (userId: number) => {
const jwtSignOptions = Object.assign(
{ algorithm: "HS256" },
{},
{
issuer: "yourAPI.com",
audience: "yourAPI.com:client",
}
);
return jwt.sign({ userId: userId.toString() }, JWT_SECRET_KEY, jwtSignOptions);
};
const generateRefreshToken = async (userId: number, tokenFamily?: string) => {
const expAt = new Date(new Date().getTime() + 31 * 24 * 60 * 60 * 1000); // Expire in 31 days
const refreshTokenExp = expAt.toISOString();
const token = await createTokenQuery({
userId,
tokenFamily,
expiresAt: refreshTokenExp,
});
return token;
};
Refresh Token Logic:
Implement logic to handle refreshing tokens securely:
const refreshToken = async ({ token }: RefreshTokenSchema) => {
const tokenData = await getRefreshToken(token);
if (!tokenData) throw forbiddenError();
const { userId, tokenFamily, active } = tokenData;
if (active) {
// Token is valid and hasn't been used yet
const newRefreshToken = await generateRefreshToken(userId, tokenFamily);
const accessToken = generateAccessToken(userId);
return { accessToken, refreshToken: newRefreshToken };
} else {
// Previously refreshed token used, invalidate all tokens in family
await invalidateRefreshTokenFamily(tokenFamily);
throw forbiddenError();
}
};
Learn more about refresh tokens and Automatic Reuse Detection in this Auth0 article.
By following this guide, you’ve built a simple, secure authentication system for your Node.js API with minimal dependencies. This approach ensures you have full control and adhere to modern best practices for security.
If you want to save time and effort, check out Vratix. Our open-source CLI can set up a fully functional Node.js project with authentication in seconds. Explore our fully implemented authentication module on GitHub.
Let us know in the comments if this was helpful, or connect with us on X.
Top comments (0)