Tiếng Việt

I’ve previously shared how I approach a backend system in my post How I Optimize a Backend System. Today, I’ll walk you through another story how I applied those principles in a real-world scenario.

One fine day, a client reached out to me in a panic: Their backend system was constantly overloaded, leading to high latency and a seriously degraded user experience. After digging in, I discovered a small change that delivered jaw-dropping results, boosting throughput by 30% without upgrading hardware or scaling the database. Let’s explore it together!

Pinpointing the Cause of the Bottleneck

The client’s system included a management dashboard using Role-Based Access Control (RBAC):

  • Admin: Full system management rights.
  • Editor: Can edit content but not manage users.
  • Viewer: Read-only access to data.

Most requests required user authentication to verify access rights. After some analysis, I identified the main bottleneck in the database layer. On average, each request triggered three queries, and I wanted to cut that number down. One of those queries came from the authMiddleware to fetch user info:

function authMiddleware(req, res, next) { const token = req.headers.authorization?.split(" ")[1]; if (!token) return res.status(401).json({ message: "Unauthorized" }); try { const decoded = jwt.verify(token, SECRET_KEY); const user = await getUserRecordFromDatabase(decoded.userId); req.user = user; next(); } catch (error) { return res.status(403).json({ message: "Invalid token" }); } }

This query took about 0.01 seconds per call:

SELECT u.id, u.username, u.email, r.id, r.name, p.id, p.name FROM USERS u LEFT JOIN USER_ROLES ur ON u.id = ur.user_id LEFT JOIN ROLES r ON ur.role_id = r.id LEFT JOIN ROLE_PERMISSIONS rp ON r.id = rp.role_id LEFT JOIN PERMISSIONS p ON rp.permission_id = p.id WHERE u.id = 'USER_ID_HERE';

What’s the Problem? If the system handles 1000 requests per second, just authenticating users generates 1000 queries per second to the database! Meanwhile, the database can only manage around 1000 queries per second total. I asked myself: Could I eliminate this query entirely?

Solution: Embedding Roles and Permissions in JWT

In reality, most requests only need userId, role, and permissions for authorization. So why not store this info directly in the JWT payload? This way, we can eliminate the unnecessary database query.

The new JWT would look like this:

{ "userId": "123456", "role": "editor", "permissions": ["edit_articles", "view_dashboard"], "iat": 1716220000, "exp": 1716223600 }

Update authMiddleware to use data from the JWT instead of querying the database:

function authMiddleware(req, res, next) { const token = req.headers.authorization?.split(" ")[1]; if (!token) return res.status(401).json({ message: "Unauthorized" }); try { const decoded = jwt.verify(token, SECRET_KEY); req.user = decoded; next(); } catch (error) { return res.status(403).json({ message: "Invalid token" }); } }

Thanks to this, each request saves one database query, reducing the average number of queries from 3 to 2.

Handling Changes in Permissions

But hold on! If a user’s role or permissions change, or if the user is deleted, the old token remains valid. This could lead to a security vulnerability.

My solution is to use Redis to manage token validity:

  • When a role or permissions change, or a user is deleted, set a flag in Redis: tokenPayloadExpired::<userId>.
async function genToken(userId, role, permissions) { const newTokens = // gen new refresh token and accessToken await redisService.delete(`tokenPayloadExpired::${userId}`); return newTokens; }

Update authMiddleware:

async function authMiddleware(req, res, next) { const token = req.headers.authorization?.split(" ")[1]; if (!token) return res.status(401).json({ message: "Unauthorized" }); try { const decoded = jwt.verify(token, SECRET_KEY); if (await redisService.get(`tokenPayloadExpired::${decoded.userId}`)) { throw Error("Token expired"); } req.user = decoded; next(); } catch (error) { return res.status(403).json({ message: "Invalid token" }); } }

Results Achieved

Average number of queries per request dropped from 3 to 2.
Average response time reduced by 80-90ms per request.
System throughput increased by about 30%.
Database load decreased significantly, making the system more stable.

Summary

If you’re facing a similar situation, give this approach a shot! 🚀

How have you optimized authentication in your projects? Feel free to share your thoughts—hehe!