Authentication vs Authorization: Understanding the Difference
I built the messaging rule correctly and put it in the wrong place: the contact list filtered what the app showed, while the endpoint underneath would talk to anyone. How a real authorization bug happens, and what mutation-testing my own fix revealed.
Harrison Onyango Aloo
Backend Software Engineer — Node.js · Python · Payment Integrations
Authentication and authorization are two of the most fundamental concepts in application security, and they are constantly mistaken for the same thing.
The distinction is easy to state. Authentication answers who are you. Authorization answers what are you allowed to do. Every tutorial says this, and saying it does not help, because nobody ships a bug from misunderstanding the definitions.
The bug I shipped came from something else: I built the authorization rule correctly, put it in the wrong place, and could not tell the difference for months. This is that story, and what it taught me about how authorization actually fails.
Authentication: "Who Are You?"
Authentication verifies identity. Common mechanisms: passwords, MFA, JSON Web Tokens, session cookies, OAuth 2.0, API keys.
Here is a minimal JWT middleware in Express:
const jwt = require("jsonwebtoken");
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "Not logged in" });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: "Invalid session" });
}
}
Attach it to your routes and every request now arrives with a verified identity. This part is well-documented, and most people get it right.
It also produces the most dangerous feeling in backend development: the sense that the endpoint is now protected.
Authorization: "What Are You Allowed to Do?"
Authorization decides what a verified identity may do. The textbook version is role-based:
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}
app.delete("/api/jobs/:id", authenticate, requireRole("admin"), deleteJob);
Roles are the easy case, because the answer lives on the user. req.user.role is right there in the token — no query, no context, no relationship to reason about.
Most real authorization is not like that.
The rule that isn't a role
I built FishCrewConnect, a job marketplace connecting boat owners with fishermen in Kenya. Boat owners post jobs. Fishermen apply. Both sides message each other to arrange work.
Messaging on a marketplace of strangers needs a rule. Two things go wrong without one: people spam every user on the platform, and people take deals off-platform to avoid the commission. The platform's entire commercial model depends on conversations happening inside it.
So the rule was: you can only message someone you share a job application with. Either you applied to their job, or they applied to yours.
Notice what that rule is made of. It is not a property of the user — nothing on the sender's account says whether they may message user 42. It is a property of the relationship between two users, and that relationship lives in a table:
SELECT 1
FROM job_applications ja
INNER JOIN jobs j ON j.job_id = ja.job_id
WHERE ja.user_id = ? AND j.user_id = ? -- I applied to their job
You cannot answer this from a token. You have to ask the database.
Where I put it
I wrote that query the day I built messaging. It went into GET /api/users/contacts — the endpoint that returns your contact list:
// Fisherman: everyone whose job I applied to
// Boat owner: everyone who applied to my jobs
It worked. Open the app, and you see exactly the people you're connected to. Nobody else appears. There is no button to message a stranger, because strangers are not on the list.
I shipped that and considered messaging authorization done.
The bug
Here is what POST /api/messages actually validated:
// recipientId is present and numeric ✓
// text is a non-empty string under 5000 ✓
// you are not messaging yourself ✓
// the recipient exists in the users table ✓
// → INSERT
Read the list again. Nothing checks whether an application connects the two people.
The contact list filtered what the app displayed. The endpoint underneath would talk to anybody. Any authenticated user could send this and reach a total stranger:
POST /api/messages
Authorization: Bearer <any valid token>
{ "recipientId": 42, "text": "hi" }
No UI ever offered that button. The endpoint did not need the UI. curl does not render your contact list.
And the exposure was exactly what the rule existed to prevent: enumerate user IDs, message every fisherman on the platform, spam or move deals off-platform. Months of it sitting in a public repository, in code I would have told you was authorized.
Why this is the most common authorization bug there is
OWASP puts Broken Object Level Authorization at number one on the API Security Top 10, and this is its most common shape: filter the list, forget the action.
It happens for a specific reason. When you're building, the list and the action feel like one feature — "messaging" — so once the list is right, the feature looks right. You test it by using the app, and the app only ever sends IDs that came from the filtered list. Every manual test passes. The app is not the attacker.
Which gives the rule I now apply everywhere:
A filtered list is a convenience, not a control. If a rule matters, it has to be enforced where the state changes — not where the options are displayed.
Authentication tells you the request came from a real user. It says nothing about whether this user may act on this object. That gap is where the bug lives, and it is why "we use JWTs" is not an answer to "is it authorized".
The fix
One function, called from every path that can send a message:
const canUsersMessage = async (senderId, recipientId) => {
const sql =
"SELECT 1 FROM users WHERE user_id IN (?, ?) AND user_type = 'admin'" +
' UNION ALL ' +
`SELECT 1 ${AS_APPLICANT} AND j.user_id = ?` + // sender applied to recipient's job
' UNION ALL ' +
`SELECT 1 ${AS_OWNER} AND ja.user_id = ?` + // recipient applied to sender's job
' LIMIT 1';
const [rows] = await db.query(sql, [
senderId, recipientId,
senderId, recipientId,
senderId, recipientId,
]);
return rows.length > 0;
};
Then in the send handler, before anything is written:
if (!(await canUsersMessage(senderId, parsedRecipientId))) {
logger.warn(`User ${senderId} attempted to message unconnected user ${parsedRecipientId}`);
return res.status(403).json({ error: 'NOT_CONNECTED' });
}
Four decisions in there are worth more than the code.
One function, not two. The contact list and the send handler now call the same thing. Two copies of an authorization rule will drift, and the copy you test less is the one that breaks. I know this because it happened during this fix — I patched the real-time socket path with its own inline query, and within a single change the two versions already disagreed about admins.
Any application status counts. Pending, accepted, and rejected all permit messaging. Requiring accepted would break the main reason to message at all — screening an applicant, or asking about a job, before anyone commits. Excluding rejected would make an open thread unrepliable the moment the owner filled the job. A rejection is an outcome, not a block. Blocking is a different feature and needs its own mechanism.
Either party may be an admin. My first version checked only the sender's type. Admins could reach users; users got a 403 trying to reply. Support that works in one direction is not support.
The account type is read from the database, not the token. user_type in a JWT is whatever it was at login, and my tokens last eight hours. Revoke someone's admin and their token still claims it. Authorization gates should not run on stale claims — read the current state, and pay the query.
The part I didn't expect
I wrote tests. Six of them, covering the 403, both directions, the admin case, the 404. All green. Then I mocked the database, which meant the SQL never ran — so I decided to check whether the tests could actually fail.
I broke the code on purpose, five different ways, and reran the suite:
| Mutation | Mocked tests (16) | Integration test |
|---|---|---|
| Delete the owner-side branch (one direction broken) | all pass | fails |
| Contact list loses the owner side | all pass | fails |
Require accepted only | fails | fails |
| Join on the wrong column | fails | fails |
| Check only the sender for admin | all pass | fails |
Three of five broken versions passed every mocked test. Delete an entire direction of the authorization rule — boat owners can no longer message their applicants — and sixteen green checkmarks say it's fine.
The reason is simple in hindsight. Mocking db.query tests how the controller reacts to what the query returns. The rule itself lives in the SQL, and SQL that never executes is not tested. My tests proved "if the connection check returns nothing, we return 403" — genuinely worth having, and not the same thing as "the connection check is correct."
So I added one integration test against a real MySQL database: a job owned by one user, an application from another, a stranger, an admin. It catches all five mutations. It is worth more than the sixteen tests above it, because it tests the thing that can actually be wrong.
If you take one habit from this article, take that one: break your code on purpose and confirm the test fails. A test you have never seen fail is a test you are trusting for no reason.
Authentication vs Authorization
| Authentication | Authorization | |
|---|---|---|
| Question | Who are you? | What may you do? |
| Runs | Once per request, up front | At every state change |
| Source of truth | The token | Usually the database |
| Failure | 401 Unauthorized | 403 Forbidden |
| Fails visibly? | Yes — you can't log in | No — everything looks fine |
That last row is the whole problem. Broken authentication is loud: nobody gets in. Broken authorization is silent. The app works, the tests pass, the users are happy, and the hole sits there until someone curious sends a request your UI never offered.
What I'd tell myself
- Enforce at the write, not at the read. Filtering a list is UX. Authorization happens where state changes.
- One function, every path. REST, WebSocket, admin tools, background jobs. Duplicated rules drift, and they drift fast.
- Roles are the easy case. Real rules are relationships, and relationships live in your database, not your token.
- Don't trust claims in a token for gates. They were true at login. Read current state.
- Verify your tests can fail. Break the code deliberately. If the suite stays green, the suite is decoration.
- Write down the decisions. Why any status counts, why either party may be an admin — the next person to read it is you, and you will not remember.
Authentication was never the hard part. Knowing where the check belongs is.
Building an API and unsure whether your authorization is real or cosmetic? I'm a backend engineer in Nairobi working on payments and platform systems — get in touch.