I Built a Role-Based Approval System From Scratch — Here's Why One Login Page Took Me Three Architecture Rewrites

By Ayesha Zafreen S | 3rd Year CS-IoT Student | Sri Sairam Engineering College
The Question That Started Everything
"Can users register themselves on the portal?"
Simple question. I almost said yes without thinking. Then I asked myself — in a real company, can a new hire just sign up and grant themselves "Manager" access?
No. Obviously not.
That one realization changed the entire architecture of what I was building, and it's the reason this project ended up teaching me more about backend design than any tutorial ever did.
What I Set Out To Build
An Employee Workflow Portal — employees submit requests (leave, expense, access) with PDF proof, managers approve or reject them. Standard internal company tool. I'd seen the pattern before in my internship.
But the more I thought about who controls what, the more I realized I was designing two completely different systems that happened to live in the same app.
The Two-Layer Approval System
Layer 1 — Who Gets In At All
New user registers → selects requested role (employee or manager)
→ status: "pending"
→ CANNOT log in yet
Admin reviews → approves (confirms or overrides the role)
→ OR rejects
Only approved users can authenticate
Layer 2 — What They're Allowed To Do Once They're In
Employee submits a request + PDF proof → status: "pending"
Manager or Admin reviews → approves or rejects
Employee can edit details or replace the PDF — but ONLY while still pending
Once reviewed, the request locks permanently
These look similar on the surface — both are "pending → approved/rejected" workflows. But they answer fundamentally different questions, reviewed by fundamentally different authorities. A manager can decide if someone's leave request is valid. A manager should not be able to decide if a new hire gets admin access to the entire system. That's an admin-only decision.
This is a real security principle called separation of duties — and once I understood it, I couldn't unsee it everywhere in good system design.
Why I Locked Requests After Review
This one came from a genuinely unsettling thought experiment.
Imagine: an employee submits an expense claim with a PDF receipt. A manager reviews the receipt, approves it. Then the employee edits the request afterward and swaps in a different PDF.
Now there's an approved request in the system with evidence the manager never actually saw. The audit trail is broken — what's stored no longer reflects what was actually decided.
if (existing.rows[0].status !== 'pending') {
return res.status(400).json({
error: 'Cannot edit a request that has already been reviewed'
});
}
One condition. But it's the difference between a system you can trust and one you can't. This is the same principle behind why you can't edit a bank transaction after it clears.
The Bug That Taught Me The Most
Everything was working perfectly. I went and worked on a different project for a few hours. Came back, made some edits to my controller, restarted the server (or so I thought), and started getting a mysterious 500 error with a PostgreSQL syntax error that made no sense.
I added console.log statements at the very top of my middleware. Nothing printed. Not even "middleware hit."
That silence was the clue. If a request reaches your server at all, the first line of code in your route MUST execute. If nothing printed, the request wasn't reaching my actual code — it was being intercepted somewhere else.
I checked netstat -ano | findstr :3000 and found a process still listening on port 3000. I'd never actually stopped my server from hours earlier — I'd just switched terminal tabs. The old process kept running in the background with stale code, silently handling every request while I edited a file it had no idea existed.
taskkill /PID 3316 /F
node index.js
Killed it. Restarted properly. Everything worked instantly.
The lesson: Node.js does not hot-reload by default. Every code change requires a genuine restart, and closing a terminal tab does not guarantee the process actually dies. I now use nodemon so this can never happen again — but more importantly, I learned to read silence as data. No log output is itself a clue, not an absence of information.
The Production Bug Nobody Warns You About
Deployment went smoothly until it didn't. My app deployed successfully on Render, server started, and then immediately:
Connection error: Error: connect ENETUNREACH 2406:da1a:b00:1302:...
My database, hosted on Supabase, was unreachable — but only in production. Locally everything connected fine.
The address in the error was an IPv6 address. Render's outbound networking on the free tier doesn't reliably support IPv6, but Supabase's direct connection string was resolving to an IPv6 address by default.
The fix wasn't a workaround — it was using the tool correctly. Supabase provides a connection pooler specifically designed for external platforms connecting in, and it resolves over IPv4:
Direct connection: db.xxxxx.supabase.co
Pooler connection: aws-1-ap-south-1.pooler.supabase.com
Different host, different username format (postgres.projectref instead of just postgres), but otherwise a drop-in replacement. One environment variable change in Render's dashboard, and the connection succeeded immediately.
This taught me something tutorials rarely cover: cloud providers don't always have identical networking compatibility with each other, and production debugging often means reading the actual error message carefully rather than assuming your code is wrong.
The Security Principle I Refused To Compromise On
Early in building the frontend, it would have been easy to just hide the "Approve" and "Delete" buttons based on the logged-in user's role:
if (currentUser.role === 'employee') {
hideApproveButtons();
}
This feels like security. It is not. Anyone can open browser DevTools and either re-show those buttons or just call the API directly with a tool like Postman, bypassing the UI entirely.
The actual security has to live on the server:
router.delete('/:id', verifyToken, verifyRole('admin'), deleteRequest);
Every single protected action in this project is verified through backend middleware before it ever executes. The frontend hiding buttons is just UX politeness — making the interface match what a user can do. The backend middleware is the actual guarantee of what they are allowed to do.
This distinction — between authentication (who are you) and authorization (what are you allowed to do) — became the principle I kept coming back to throughout the entire build.
What This Project Actually Taught Me
Not "how to use Express" — I could've learned that from documentation in an afternoon.
What I actually learned:
Real systems model real authority structures. The two-layer approval design wasn't a technical requirement — it came from thinking about how trust and permission actually work in an organization, then translating that into database constraints and middleware checks.
Silence is data. A missing log line told me more than an error message did.
Production breaks differently than local. Networking, IPv6 vs IPv4, connection poolers — none of this exists in a tutorial's "hello world" example. It exists the moment you deploy something real.
Never trust the client. Every guarantee that matters has to be enforced where the user can't reach it — on your server.
Concepts Explained :
The story above covers the why. Here's the how, concept by concept.
Connection Pooling
const pool = new Pool({ host, port, database, user, password });
Opening a database connection involves a network handshake and authentication — expensive if done on every single request. A Pool keeps a set of connections open and ready, handing them out to requests as needed and returning them when done. Instead of "open connection → query → close connection" for every single API call, the pool reuses warm connections. This is why pool.query() stays fast even under repeated load — you're borrowing, not recreating.
Parameterized Queries — \(1, \)2
pool.query('SELECT * FROM requests WHERE submitted_by = $1', [userId]);
$1 is a placeholder, never directly concatenated into the SQL string. The database driver sends the query structure and the values separately — so even if userId contained malicious SQL text, it would be treated as a literal value, never as executable SQL. This is the standard defense against SQL injection, and it should be used for every single query that includes user input, with no exceptions.
COALESCE — Conditional Fallback in SQL
UPDATE users SET role = COALESCE(\(2, role) WHERE id = \)3
COALESCE returns the first non-null value from its arguments. Here, it means: "use the new role if the admin provided one, otherwise keep the existing role unchanged." This let the approve-user endpoint support both "approve with the role they requested" and "approve but override to a different role" using one query, instead of writing two separate code paths.
JWT Signing and Verification
jwt.sign({ id, email, role }, process.env.JWT_SECRET, { expiresIn: '24h' });
A JWT has three parts: header, payload, and signature. The signature is generated by running the header and payload through a hashing algorithm combined with your server's secret key. Anyone can read the payload (it's just base64, not encrypted) — but only someone with the secret can produce a valid signature for it.
jwt.verify(token, process.env.JWT_SECRET);
Verification recalculates what the signature should be using your secret, and compares it to the signature actually present on the token. If a user tampered with the payload (say, changing role: "employee" to role: "admin"), the signature no longer matches what the server recalculates — and jwt.verify throws, rejecting the token. This is the entire trust model: tampering is detectable, not preventable by hiding the data, but by making any change break verifiably.
localStorage on the Frontend
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
localStorage is a small key-value store built into the browser, scoped to your site's origin, persisting even after the tab closes. After login, the JWT and basic user info are stored here so that every subsequent page load doesn't require logging in again — the dashboard reads the token from localStorage on init() and attaches it to every API request:
fetch('/requests', { headers: { 'Authorization': `Bearer ${token}` } });
Logging out is simply:
localStorage.removeItem('token');
localStorage.removeItem('user');
The token is gone client-side; since JWTs are stateless, the server doesn't need to "invalidate" anything — it just won't see that token again. (For higher-security systems, you'd add server-side token blacklisting or short expiry with refresh tokens — a deliberate simplification here given the scope.)
Multer and Disk Storage
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname)
});
HTTP requests with file attachments use a different encoding (multipart/form-data) than JSON — Express can't parse this natively, which is exactly the gap Multer fills. diskStorage tells Multer two things: where to save uploaded files (the uploads/ folder) and what to name them. Prefixing the filename with Date.now() guarantees uniqueness — two employees both uploading proof.pdf won't collide and overwrite each other, because the actual stored names become things like 1781780680109-proof.pdf and 1781780681205-proof.pdf.
const fileFilter = (req, file, cb) => {
if (file.mimetype === 'application/pdf') cb(null, true);
else cb(new Error('Only PDF files allowed'), false);
};
The fileFilter runs before the file is saved, rejecting anything that isn't a PDF based on its MIME type — a basic but important validation layer before untrusted files touch the filesystem.
Array .filter() for Client-Side Table Filtering
function filterTable() {
const status = document.getElementById('filterStatus').value;
const type = document.getElementById('filterType').value;
let filtered = allRequests;
if (status) filtered = filtered.filter(r => r.status === status);
if (type) filtered = filtered.filter(r => r.type === type);
renderTable(filtered, 'tableWrap', true);
}
allRequests is the full array fetched from the API once. Rather than hitting the backend again every time a dropdown changes, .filter() runs entirely in the browser — it walks the array and keeps only elements where the callback returns true. Chaining two .filter() calls (status, then type) narrows the array down by both conditions. This keeps the UI responsive instantly, with zero network latency, since the filtering never leaves the client.
Dynamic Table Rendering with .map().join('')
const rows = requests.map(r => {
return `<tr>...</tr>`;
}).join('');
wrap.innerHTML = `<table>...${rows}</table>`;
Instead of manually writing HTML for every row, .map() transforms each request object into its corresponding <tr> string, producing an array of HTML fragments. .join('') concatenates them into one continuous string with no separator. Setting innerHTML once with the complete table is far more efficient than appending rows one at a time — the browser only has to parse and render the DOM once, not on every iteration.
This pattern — fetch data once, derive UI from it with .map(), re-render on state change — is the same conceptual foundation that frameworks like React formalize and optimize further. Understanding it manually here makes that transition much more intuitive later.
Try It
🔗 Live demo: workflow-portal.onrender.com
🔗 Source code: github.com/Ayeshaa-w/workflow-portal
Built with Node.js, Express, PostgreSQL (Supabase), JWT authentication, and deployed on Render.
If you're a beginner building your first full-stack project — design for who's allowed to do what before you write a single route. The architecture follows from the authority structure, not the other way around.
