Skip to main content
Auth, Identity & Security
🔐 Auth & SecurityLesson 1 of 13

Authentication vs Authorization

Who you are vs what you're allowed to do — and why confusing them causes breaches.

Authentication vs Authorization

Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L

You built the login page in an afternoon. It works. Users sign in, the dashboard appears, everyone's happy. Then someone types /admin in the URL bar — and walks right in. Congrats: you authenticated them perfectly and forgot to authorize anything.


⚠️ The vibe trap

When you're moving fast with an AI co-pilot, it's easy to add a login screen and feel like "security is done." But authentication (proving who you are) and authorization (deciding what you're allowed to do) are two completely separate systems. Skipping the second one is one of the most common causes of real-world data breaches — and the exploit is usually a single missing middleware call. This lesson is about building both, cleanly, so they can never get tangled.


🔑 Authentication — "Who Are You?"

Authentication is the process of verifying a claimed identity. The user says "I'm Alice." Auth proves it.

// middleware/authenticate.js
import jwt from 'jsonwebtoken';

export function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1]; // "Bearer <token>"

  if (!token) {
    return res.status(401).json({ error: 'No token provided. Please log in.' });
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = payload; // { id, email, role } — now the request knows WHO is asking
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token.' });
  }
}

Mental model: This is the bouncer checking your ID at the door. He doesn't care which room you want — he just needs to know you're actually you before you take one step inside.

Why it matters: Every request that touches user-specific data must first go through authentication. Without it, an attacker can just fabricate a request with any user ID they feel like.

Common mistake: Checking the token in the frontend only. JavaScript in the browser can be deleted, overridden, or bypassed entirely. The backend must always verify the token itself — the frontend check is purely cosmetic.


🛂 Authorization — "Are You Allowed?"

Authorization happens after authentication. The request already has a verified identity attached. Now we ask: does this identity have permission to do this specific thing?

// middleware/authorize.js

export function authorize(...requiredRoles) {
  return (req, res, next) => {
    // authenticate() must have run first — req.user must exist
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated.' });
    }

    if (!requiredRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: `Forbidden. Required role: ${requiredRoles.join(' or ')}.`,
      });
    }

    next();
  };
}

Mental model: This is the velvet rope inside the club. The bouncer already let you in. Now the VIP section host checks your wristband. Being inside the club doesn't mean you can go everywhere.

Why it matters: Conflating the two means you end up checking "is the user logged in?" where you should be checking "does this logged-in user have permission?" An authenticated regular user should never be able to reach admin routes, other users' private data, or destructive operations.

Common mistake: Returning 401 when you mean 403. They are not interchangeable — more on this in the next section.


🔢 401 vs 403 — The Status Codes That Tell the Story

These two codes describe fundamentally different security states, and using the wrong one leaks information or breaks client behavior.

// routes/adminRouter.js
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate.js';
import { authorize } from '../middleware/authorize.js';

const router = Router();

// Stack order matters: authenticate FIRST, authorize SECOND, always.
router.get(
  '/admin/dashboard',
  authenticate,           // 401 if no valid token
  authorize('admin'),     // 403 if valid token but wrong role
  (req, res) => {
    res.json({ message: `Welcome, admin ${req.user.email}` });
  }
);

// A route with no auth — still intentional, not an accident
router.get('/public/status', (req, res) => {
  res.json({ status: 'ok' });
});

export default router;
CodeMeaningWhen to use
401 UnauthorizedIdentity unknown — please authenticateMissing token, invalid token, expired session
403 ForbiddenIdentity known, permission deniedValid token, but wrong role / insufficient privilege

Mental model: 401 = "I don't know who you are." 403 = "I know exactly who you are, and the answer is no."

Why it matters: A correct 403 tells the client (and your logs) that a real authenticated user hit a wall. That's useful signal — maybe it's a bug, maybe it's a probe. A 401 in that same spot would incorrectly send the user back to the login screen for no reason.

Common mistake: Returning 403 when no token was provided at all. Now your error messages are misleading and your logs are polluted.


🧱 Separation of Concerns — Keep Them Independent

The biggest architectural mistake is fusing authentication and authorization into one giant middleware. Separate them so each can evolve independently.

// routes/postsRouter.js — a real route file showing all three cases
import { Router } from 'express';
import { authenticate } from '../middleware/authenticate.js';
import { authorize } from '../middleware/authorize.js';
import { PostService } from '../services/PostService.js';

const router = Router();

// Public — no auth needed at all
router.get('/posts', async (req, res) => {
  const posts = await PostService.getPublished();
  res.json(posts);
});

// Authenticated, but any role can read their own draft
router.get('/posts/:id/draft', authenticate, async (req, res) => {
  const post = await PostService.getDraft(req.params.id, req.user.id);
  if (!post) return res.status(404).json({ error: 'Not found' });
  res.json(post);
});

// Authenticated + authorized — only editors or admins can publish
router.post(
  '/posts/:id/publish',
  authenticate,
  authorize('editor', 'admin'),
  async (req, res) => {
    const post = await PostService.publish(req.params.id);
    res.json(post);
  }
);

// Authenticated + authorized — only admins can delete
router.delete(
  '/posts/:id',
  authenticate,
  authorize('admin'),
  async (req, res) => {
    await PostService.delete(req.params.id);
    res.status(204).send();
  }
);

export default router;

Mental model: Think of it as two separate gates in sequence. You can swap out the lock on either gate without rebuilding the other. Tomorrow you might replace JWT with session cookies — the authorize middleware doesn't care. Next month you might move from role-based to attribute-based permissions — the authenticate middleware doesn't care.

Why it matters: Tight coupling means a change to your token format breaks your permission logic. Loose coupling means each layer is testable in isolation and replaceable without fear.

Common mistake: Writing one mega-function called authMiddleware that both verifies the token AND checks roles, using if/else chains that multiply as your app grows. This becomes unmaintainable fast.


⚖️ Principle of Least Privilege

Every user, service, and API key should have the minimum permissions required to do its job — nothing more.

// Good: roles are scoped tightly
const ROLES = {
  viewer:    ['read:own_profile', 'read:public_posts'],
  author:    ['read:own_profile', 'read:public_posts', 'write:own_posts'],
  editor:    ['read:own_profile', 'read:public_posts', 'write:own_posts', 'write:others_posts', 'publish:posts'],
  admin:     ['*'], // all permissions — granted sparingly
};

// Checking a specific permission (more granular than role-only checks)
export function requirePermission(permission) {
  return (req, res, next) => {
    const userPermissions = ROLES[req.user?.role] ?? [];
    const hasPermission =
      userPermissions.includes('*') || userPermissions.includes(permission);

    if (!hasPermission) {
      return res.status(403).json({
        error: `Missing permission: ${permission}`,
      });
    }
    next();
  };
}

Mental model: Give a contractor a key to one room, not a master key to the building. If their key gets stolen, the damage is limited.

Why it matters: Over-privileged accounts are how breaches escalate. An attacker who compromises a viewer account in a well-designed system can do almost nothing. An attacker who compromises a viewer account that was secretly given admin permissions because "it was easier" owns your entire platform.

Common mistake: Giving every user admin role during development because it's easier to test, then forgetting to lock it down before shipping to production.


🛠️ Your Mission

Go into your existing vibe-coded app and audit the authentication/authorization boundary.

  1. Find every route or page that currently only checks "is the user logged in?" — list them.
  2. For each one, ask: who should actually be allowed here? Is this "any authenticated user" or "only users with role X"?
  3. Separate your auth check from your permission check into two distinct functions (or middlewares). They should live in different files.
  4. Confirm that your 401 and 403 responses are used correctly throughout the app.
  5. Add at least one role-restricted route (even if the only role is 'admin') and verify the 403 response fires when a non-admin hits it.
  6. Check every sensitive action (delete, publish, edit-other-user's-content) and confirm it has an explicit authorization check — not just an authentication check.

✅ You're done when…

  • You have added this lesson's findings to your Security Audit Checklist — specifically: "AuthN and AuthZ are implemented as separate, independent checks on every protected route."
  • Every backend route is explicitly categorized: public / authenticated / authenticated+authorized — and the code reflects that categorization.
  • You can trigger a real 401 (no token) and a real 403 (wrong role) on demand and verify the correct status codes in the browser DevTools Network tab.
  • No route returns 403 when the token is missing, and no route returns 401 when the token is valid but the role is wrong.
  • The principle of least privilege is documented for your app's roles — even if it's just a comment block listing what each role can do.

➡️ Next: Passwords Done Right. Build It Right, Or Don't Build It At All. 🏛️

Pre-launch security · partner tool

Built it? Now scan it. The HYVE Audit finds security holes before launch — $55, and your code never leaves your machine.

Run the audit ↗

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.