Skip to content
← Auth & Security · beginner · 8 min · 01 / 08

Authentication vs Authorization

Two different questions: who are you, and what are you allowed to do. Confusing them is how security holes form.

authenticationauthorizationsessionstokensRBAC

Real-World Analogy

A concert venue: the ticket scanner at the entrance checks you have a valid ticket — that’s authentication. The wristband you get says “VIP” or “General Admission” — that’s authorization. Two separate jobs, two separate moments, two separate people doing them.

The Core Distinction

Authentication (AuthN): Verify identity. Are you who you claim to be? Authorization (AuthZ): Verify permission. Are you allowed to do this?

They happen in sequence. You can’t authorize an unknown identity. But they’re separate systems with separate logic. Mixing them creates holes: a valid session that grants access it shouldn’t, or an authorization check that skips identity verification entirely.

// Authentication: verify the token, extract identity
async function authenticate(req: Request): Promise<User | null> {
  const token = req.headers['authorization']?.slice(7);
  if (!token) return null;

  try {
    const payload = await verifyJWT(token);
    return { id: payload.sub, role: payload.role, email: payload.email };
  } catch {
    return null;
  }
}

// Authorization: check if identity has permission
function authorize(user: User, action: string, resource: string): boolean {
  return permissions[user.role]?.[action]?.includes(resource) ?? false;
}

// Middleware composition — order matters
app.use(async (req, res, next) => {
  const user = await authenticate(req);
  if (!user) return res.status(401).json({ error: 'Unauthenticated' });

  if (!authorize(user, req.method, req.path)) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  req.user = user;
  next();
});

Note the status codes: 401 means “I don’t know who you are.” 403 means “I know who you are, but no.”

Identity Factors

Authentication systems verify one or more factors:

FactorWhat it isExample
KnowledgeSomething you knowPassword, PIN
PossessionSomething you haveTOTP app, hardware key
InherenceSomething you areFingerprint, face
LocationWhere you areIP range, geofence

MFA (Multi-Factor Authentication) requires two or more factors from different categories. Two passwords is not MFA — both are knowledge factors.

Sessions vs Tokens

Two approaches to persisting authentication state after the initial credential check:

Server-side sessions:

// Login: verify credentials, create session
app.post('/login', async (req, res) => {
  const user = await verifyCredentials(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  // Store session server-side
  req.session.userId = user.id;
  req.session.role = user.role;

  res.json({ ok: true });
});

// Subsequent requests: look up session
app.use(async (req, res, next) => {
  if (!req.session.userId) return res.status(401).json({ error: 'Not logged in' });

  req.user = await db.users.findById(req.session.userId);
  next();
});

Stateless tokens (JWT):

// Login: verify credentials, issue token
app.post('/login', async (req, res) => {
  const user = await verifyCredentials(req.body.email, req.body.password);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  // Embed claims in signed token — no server storage
  const token = await signJWT({
    sub: user.id,
    role: user.role,
    email: user.email,
    exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
  });

  res.json({ token });
});

Trade-offs:

SessionsTokens (JWT)
RevocationInstant (delete server record)Hard (wait for expiry)
ScalabilityRequires shared session storeStateless — any server validates
StorageServer memory/RedisClient (localStorage or cookie)
InspectionServer knows what’s activeToken is self-contained

Sessions are easier to invalidate. Tokens are easier to scale. Use sessions when you need instant revocation (admin panels, financial apps). Use tokens when you need horizontal scaling without shared state (APIs, microservices).

Role-Based Access Control (RBAC)

Assign permissions to roles, assign roles to users. Users don’t get permissions directly.

type Role = 'admin' | 'editor' | 'viewer';
type Action = 'read' | 'write' | 'delete';
type Resource = 'posts' | 'users' | 'settings';

const rolePermissions: Record<Role, Partial<Record<Resource, Action[]>>> = {
  admin:  { posts: ['read', 'write', 'delete'], users: ['read', 'write', 'delete'], settings: ['read', 'write'] },
  editor: { posts: ['read', 'write'], users: ['read'] },
  viewer: { posts: ['read'] },
};

function can(role: Role, action: Action, resource: Resource): boolean {
  return rolePermissions[role]?.[resource]?.includes(action) ?? false;
}

// Usage
can('editor', 'delete', 'posts')  // false
can('admin', 'delete', 'posts')   // true
can('viewer', 'read', 'posts')    // true

RBAC works until roles get too granular (“editor-but-only-their-own-posts”). At that point, move to Attribute-Based Access Control (ABAC) where permissions are policies evaluated against attributes of the user, resource, and environment.

Attribute-Based Access Control (ABAC)

interface AuthContext {
  user: { id: string; role: string; department: string };
  resource: { ownerId: string; visibility: 'public' | 'private'; classification: string };
  environment: { time: Date; ipAddress: string };
}

type Policy = (ctx: AuthContext) => boolean;

const policies: Record<string, Policy> = {
  'posts:delete': ({ user, resource }) =>
    user.role === 'admin' || user.id === resource.ownerId,

  'documents:read': ({ user, resource }) =>
    resource.visibility === 'public' ||
    user.department === resource.classification ||
    user.role === 'admin',
};

function evaluate(action: string, ctx: AuthContext): boolean {
  const policy = policies[action];
  if (!policy) return false; // deny by default
  return policy(ctx);
}

ABAC is more expressive but harder to reason about. RBAC is simpler to audit — you can enumerate what each role can do. Pick based on how complex your access patterns actually are.

Common Mistakes

Checking authorization before authentication:

// WRONG — user might be null
if (req.user.role !== 'admin') return res.status(403).json(...);

// RIGHT — fail on missing identity first
if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });

Trusting client-supplied role claims without verification:

// WRONG — client controls this
const role = req.body.role;
if (role === 'admin') grantAdminAccess();

// RIGHT — role comes from verified token or database
const { role } = req.user; // set by auth middleware from verified JWT

Failing open instead of closed:

// WRONG — unknown actions grant access
function canAccess(role: string, action: string): boolean {
  if (action === 'admin_only') return role === 'admin';
  return true; // default allow — dangerous
}

// RIGHT — deny by default
function canAccess(role: Role, action: Action, resource: Resource): boolean {
  return rolePermissions[role]?.[resource]?.includes(action) ?? false;
  // undefined → false
}