Authorization Best Practices

Easy•

Authorization is access-control decision making. A strong frontend answer separates UX gating from enforcement, explains object-level checks, and shows how policy mistakes become real data leaks or privilege escalation.

Quick Decision Guide

The one-line answer: Authorization decides whether a known or anonymous actor is allowed to perform a specific action on a specific resource.

Authentication vs authorization: Authentication proves identity. Authorization evaluates permission. A signed-in user is not automatically allowed to do everything.

Server rule: Frontend checks are UX hints. Server-side policy enforcement is the security boundary.

Minimum correct check: can(user, action, resource, context) is stronger than user.role === 'admin' because it includes ownership, tenant, object state, plan, and risk context.

Default posture: least privilege, deny by default, validate every request, fail closed, log meaningful denials, and test policy behavior.

Interview signal: Talk about IDOR/BOLA, object-level authorization, tenant isolation, RBAC vs ABAC/ReBAC, 401 vs 403, and why hiding a button is not enough.

Authn vs Authz

The Distinction

Authentication answers:

Who are you?

Authorization answers:

What are you allowed to do here?
SituationAuthenticationAuthorization
User logs in with password/passkeyVerify identityNot decided yet
User opens /adminUser may be knownCheck admin permission
User fetches /invoices/123User may be knownCheck tenant and invoice access
User clicks Delete ProjectUser may be knownCheck action, project role, ownership, state
Anonymous user opens public docsNo login neededPublic read may be allowed

The Authorization Decision

Think in Four Inputs

can(subject, action, resource, context) -> allow | deny

Subject

The actor making the request: user, service account, anonymous visitor, admin, organization member, or API client.

Action

The operation: read, create, update, delete, invite, export, refund, publish, approve, impersonate.

Resource

The thing being accessed: invoice, repository, project, comment, workspace, file, organization, billing account.

Context

The surrounding facts: tenant, ownership, relationship, plan, feature flag, object state, time, IP, device trust, recent reauthentication, risk score.

Figure 1: Authorization Gate

Request
  │
  ├─ subject: user_42
  ├─ action:  delete
  ├─ resource: project_9
  └─ context: org_3, role=editor, project.locked=true
        │
        v
Policy engine / authorization function
        │
        ├─ allow -> continue operation
        └─ deny  -> stop safely, return proper response, log if needed

The biggest shift from junior to senior thinking is moving from role checks to permission decisions.

Where Enforcement Belongs

Frontend Checks Are UX

Frontend authorization improves experience:

•hide actions the user cannot use
•disable buttons with helpful explanations
•avoid impossible flows
•prevent unnecessary network calls
•render permission-aware navigation

But the frontend is not trusted. Users can modify JavaScript, call APIs directly, replay requests, change route params, or edit hidden form fields.

Server Checks Are Security

Every protected server operation must check authorization using trusted server-side data.

app.delete('/api/projects/:projectId', async (req, res) => {
  const user = req.user
  const project = await db.project.findById(req.params.projectId)

  if (!project) return res.status(404).json({ error: 'Not found' })

  const allowed = can(user, 'delete', project, {
    tenantId: project.organizationId,
    locked: project.locked
  })

  if (!allowed) return res.status(403).json({ error: 'Forbidden' })

  await db.project.delete(project.id)
  res.status(204).end()
})

The UI may hide the delete button. The API still has to reject the delete request.

Least Privilege and Deny by Default

Least Privilege

Grant the minimum access needed to perform the job. This applies vertically and horizontally.

Vertical access:

viewer < editor < admin

Horizontal access:

user can access their own invoice, not another customer's invoice

Many serious authorization bugs are horizontal. The user is authenticated and may even have the right role, but not for that specific object.

Deny by Default

If no policy explicitly allows the action, deny it.

function can(user, action, resource, context) {
  for (const rule of rules) {
    const decision = rule(user, action, resource, context)
    if (decision === 'allow') return true
    if (decision === 'deny') return false
  }

  return false
}

A missing rule should not accidentally become access. New routes, new resources, new actions, and new roles should start closed.

Policy Models

RBAC: Role-Based Access Control

RBAC maps users to roles and roles to permissions.

User -> Role -> Permissions

Good for:

•simple products
•admin/editor/viewer style access
•coarse-grained internal tools

Weaknesses:

•role explosion as the product grows
•poor fit for object ownership by itself
•risky when code checks roles instead of permissions

ABAC: Attribute-Based Access Control

ABAC uses attributes of the subject, resource, action, and environment.

allow if user.department === document.department
allow if user.plan includes export_feature
allow if request.ip is trusted and action is low risk

Good for:

•fine-grained policies
•enterprise products
•compliance-sensitive workflows
•dynamic context such as tenant, region, plan, or risk

ReBAC: Relationship-Based Access Control

ReBAC uses relationships between subjects and objects.

allow if user is owner of project
allow if user is member of organization that owns document
allow if user follows private account and is approved

Good for:

•collaboration products
•social graphs
•organizations/workspaces/projects
•inherited permissions

Practical Guidance

RBAC is often a starting point. Mature systems usually combine models:

role + relationship + resource state + tenant + plan + risk

Senior answer: roles are a policy input, not the entire policy.

Object-Level Authorization

The Most Common Failure Mode

Object-level authorization fails when an API trusts an object ID without checking whether the requester can access that object.

Bad pattern:

app.get('/api/invoices/:id', async (req, res) => {
  const invoice = await db.invoice.findById(req.params.id)
  res.json(invoice)
})

If user A changes /api/invoices/100 to /api/invoices/101, they may read user B's invoice.

Better pattern:

app.get('/api/invoices/:id', async (req, res) => {
  const invoice = await db.invoice.findFirst({
    where: {
      id: req.params.id,
      organizationId: req.user.organizationId
    }
  })

  if (!invoice) return res.status(404).json({ error: 'Not found' })

  res.json(invoice)
})

Even better: centralize the check so every path uses the same policy.

IDOR / BOLA Mental Model

IDOR and Broken Object Level Authorization are not about IDs being visible. IDs are often visible by design. The bug is missing authorization at the object boundary.

Exposed ID + missing object permission check = data leak

Opaque IDs are useful for reducing enumeration, but they do not replace access control.

Frontend Responsibility

What the Frontend Should Do

Frontend authorization is still important. It makes the product understandable and reduces user frustration.

Good frontend behavior:

•hide admin-only routes from normal navigation
•disable unavailable actions with clear copy
•show upgrade or permission-request flows where appropriate
•avoid fetching data the user obviously cannot use
•handle 401 and 403 differently
•avoid leaking sensitive resource names in UI error states
•keep route transitions from flashing unauthorized content

What the Frontend Must Not Do

Do not treat these as enforcement:

if (user.role !== 'admin') hideButton()
if (!canEdit) redirect('/dashboard')
<input type="hidden" name="userId" value="current-user-id">

These are UX controls. The server still needs the real authorization decision.

Useful Frontend Pattern

Represent permissions explicitly:

type Permission = 'project:read' | 'project:update' | 'project:delete'

type ViewerContext = {
  userId: string
  organizationId: string
  permissions: Permission[]
}

Then use that context for rendering, while the server uses trusted policy checks for enforcement.

401 vs 403 vs 404

StatusMeaningTypical UX
401 UnauthorizedRequest lacks valid authentication credentialsAsk user to sign in or refresh session
403 ForbiddenUser is authenticated or known, but not allowedShow permission message or request-access path
404 Not FoundResource does not exist, or you intentionally hide existenceShow not found without leaking resource details
/project/secret-acquisition-plan

Returning 403 may confirm the project exists. Returning 404 can be safer for cross-tenant resources.

Rule of thumb:

•use 401 when the client should authenticate
•use 403 when the client is known but not allowed
•use 404 when existence itself should not be disclosed

Do this consistently so the frontend can show the right recovery path.

Common Vulnerabilities

1. Missing Object-Level Check

/api/users/1234/profile

The API checks that the user is logged in, but not whether user 1234 belongs to the requester.

2. Role Check Without Resource Scope

if (user.role === 'manager') approve(expense)

Which team? Which region? Which expense state? Which approval limit?

3. Client-Controlled Authorization Inputs

{
  "role": "admin",
  "userId": "42",
  "organizationId": "abc"
}

Never trust role, ownership, plan, or tenant values supplied by the client.

4. Missing Checks on Secondary Paths

The main API checks permissions, but export, bulk update, search, attachment download, websocket event, or background job path does not.

5. Static File or CDN Bypass

A private file is protected in the app route but publicly accessible through direct storage/CDN URL.

6. Fail-Open Behavior

If policy lookup fails, the request should not continue as allowed.

try {
  return await policy.can(user, action, resource)
} catch {
  return false
}

Implementation Pattern

Centralize Policy, Keep It Testable

Avoid scattering role checks across every component and route.

Better shape:

type Action = 'read' | 'update' | 'delete' | 'invite' | 'export'

type AuthzContext = {
  user: User
  action: Action
  resource: Resource
  tenantId: string
}

function can({ user, action, resource, tenantId }: AuthzContext): boolean {
  if (!user) return false
  if (resource.tenantId !== tenantId) return false

  if (action === 'read') {
    return user.permissions.includes('resource:read')
  }

  if (action === 'delete') {
    return user.permissions.includes('resource:delete') && !resource.locked
  }

  return false
}

Check at the Data Boundary Too

Query scoping reduces mistakes:

const project = await db.project.findFirst({
  where: {
    id: projectId,
    organizationId: user.organizationId
  }
})

Do not fetch globally and then forget to filter. Policy checks and scoped queries should reinforce each other.

Test the Policy

Authorization deserves unit and integration tests:

viewer cannot delete project
editor can update unlocked project
editor cannot update locked project
admin cannot access another tenant
anonymous user cannot read private project

Test denials as seriously as allowed paths.

Interview Answer

QuestionStrong Answer
Is hiding a button enough?No. It improves UX, but the API must enforce authorization.
What is IDOR?Accessing an object by changing an ID when the server does not verify object-level permission.
RBAC or ABAC?RBAC is simple; ABAC/ReBAC handle fine-grained context and relationships better. Many systems combine them.
401 or 403?401 means missing/invalid authentication credentials. 403 means authenticated or known but not allowed.
Why deny by default?Missing policy should not accidentally become access. New routes and resources should start closed.
How do you test authorization?Unit-test policy decisions and integration-test sensitive routes, including denied cross-tenant and object-level cases.

Key Takeaways

1Authorization is subject + action + resource + context.
2Authentication establishes identity; authorization evaluates permission.
3Frontend gating is UX, not a security boundary.
4Server-side authorization must run on every protected operation.
5Least privilege and deny-by-default are the safest defaults.
6Object-level checks prevent IDOR/BOLA-style data leaks.
7Roles are useful inputs, but mature systems often need attributes and relationships.
8Test denied paths, not only successful workflows.