Authorization Best Practices
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 Navigation: Authn vs Authz • The Authorization Decision • Where Enforcement Belongs • Least Privilege and Deny by Default • Policy Models • Object-Level Authorization • Frontend Responsibility • 401 vs 403 vs 404
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?| Situation | Authentication | Authorization |
|---|---|---|
| User logs in with password/passkey | Verify identity | Not decided yet |
User opens /admin | User may be known | Check admin permission |
User fetches /invoices/123 | User may be known | Check tenant and invoice access |
| User clicks Delete Project | User may be known | Check action, project role, ownership, state |
| Anonymous user opens public docs | No login needed | Public read may be allowed |
The Authorization Decision
Think in Four Inputs
can(subject, action, resource, context) -> allow | denySubject
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 neededThe 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:
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 < adminHorizontal access:
user can access their own invoice, not another customer's invoiceMany 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 -> PermissionsGood for:
Weaknesses:
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 riskGood for:
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 approvedGood for:
Practical Guidance
RBAC is often a starting point. Mature systems usually combine models:
role + relationship + resource state + tenant + plan + riskSenior 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 leakOpaque 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:
401 and 403 differentlyWhat 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
| Status | Meaning | Typical UX |
|---|---|---|
401 Unauthorized | Request lacks valid authentication credentials | Ask user to sign in or refresh session |
403 Forbidden | User is authenticated or known, but not allowed | Show permission message or request-access path |
404 Not Found | Resource does not exist, or you intentionally hide existence | Show not found without leaking resource details |
/project/secret-acquisition-planReturning 403 may confirm the project exists. Returning 404 can be safer for cross-tenant resources.
Rule of thumb:
401 when the client should authenticate403 when the client is known but not allowed404 when existence itself should not be disclosedDo this consistently so the frontend can show the right recovery path.
Common Vulnerabilities
1. Missing Object-Level Check
/api/users/1234/profileThe 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 projectTest denials as seriously as allowed paths.
Interview Answer
| Question | Strong 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. |