The Authorization Problem That Kept Me Up for 3 Months
I was working on a web app project with a 5-person development team, running around 8 microservices written in Node.js. At first, authorization was simple: admins could do everything, regular users couldn’t. RBAC (Role-Based Access Control) with JWT was all we needed.
Then the business logic started to expand. The client requested: “Managers can only view reports from their own department, not from others. But a regional-level Manager can view all departments in their region. Except that Department X is currently under audit — only Auditors can access it.”
I tried cramming all that logic into the JWT middleware. Two weeks later, auth.middleware.js was 600 lines long, packed with nested if-else statements, and nobody dared touch it. Adding any new requirement had the whole team on edge — nobody knew what changing one thing would break.
Why RBAC Is No Longer Enough
RBAC assigns permissions based on roles. The problem is that real-world access decisions don’t depend solely on roles — they also depend on attributes:
- User attributes: department, region, level, contract status…
- Resource attributes: status, owner, classification, creation date…
- Environment attributes: access time, IP address, device…
When authorization logic combines multiple attributes like this, RBAC forces you to create dozens of new roles (regional-manager-excluding-audit… good luck naming them all). Or you shove it all into code — which is a one-way ticket to maintenance hell.
ABAC (Attribute-Based Access Control) solves exactly this problem: permissions are determined by policies based on combinations of attributes, not rigid roles.
Three Approaches I Tried
1. Building a Custom ABAC Engine In-App
Write a policy-engine.js module, define policies as JSON or JS objects, and evaluate them at runtime. This is fast, but policies live in the code — changing a policy means redeploying the app. With microservices, each service has its own policies, making synchronization a mess.
2. Using Casbin
Casbin is a powerful authorization library with ABAC support. Policies are stored in .conf and .csv files. This is better, but when you need complex policies with deeply nested conditions, Casbin’s syntax becomes hard to read and a pain to debug.
3. Open Policy Agent (OPA) — What I Use Now
OPA is a standalone policy engine that runs as a sidecar or separate service. Policies are written in Rego — a declarative language that’s easy to test and audit. Most importantly: policies are completely decoupled from application code. You can change authorization rules without redeploying any service.
After 6 months running OPA in production, I have no regrets.
Practical Implementation with OPA and Node.js
Step 1: Run OPA with Docker
OPA runs as an HTTP service; your microservices call its API to check permissions:
# docker-compose.yml
services:
opa:
image: openpolicyagent/opa:latest
ports:
- "8181:8181"
command:
- "run"
- "--server"
- "--log-format=json"
- "/policies"
volumes:
- ./policies:/policies
Step 2: Write Policies in Rego
Create policies/reports.rego — the policy for the reports service:
package reports
import future.keywords.if
import future.keywords.in
# Deny by default
default allow := false
# Auditors can read any report that is under audit
allow if {
input.user.role == "auditor"
input.resource.status == "under_audit"
input.action == "read"
}
# Regional managers can read reports in their own region
allow if {
input.user.role == "manager"
input.user.level == "regional"
input.action == "read"
input.resource.region == input.user.region
input.resource.status != "under_audit"
}
# Department managers can only read reports from their own department
allow if {
input.user.role == "manager"
input.user.level == "department"
input.action == "read"
input.resource.department_id == input.user.department_id
input.resource.status != "under_audit"
}
The policy is clear enough that even non-technical stakeholders can read and understand it — that’s what I love most about Rego.
Step 3: Integrate into Node.js Middleware
Create a helper to call the OPA API:
// lib/opa-client.js
const axios = require('axios');
const OPA_URL = process.env.OPA_URL || 'http://opa:8181';
async function checkPermission(policyPath, input) {
try {
const res = await axios.post(
`${OPA_URL}/v1/data/${policyPath}`,
{ input },
{ timeout: 500 } // fail fast if OPA is down
);
return res.data.result?.allow === true;
} catch (err) {
console.error('[OPA] Policy check failed:', err.message);
return false; // fail-closed: deny access if OPA is unreachable
}
}
module.exports = { checkPermission };
Then use it in Express middleware:
// middleware/authorize.js
const { checkPermission } = require('../lib/opa-client');
function authorize(policyPath, getResource) {
return async (req, res, next) => {
const user = req.user; // from verified JWT
const resource = await getResource(req);
const allowed = await checkPermission(policyPath, {
user: {
id: user.id,
role: user.role,
level: user.level,
department_id: user.department_id,
region: user.region,
},
resource,
action: req.method === 'GET' ? 'read' : 'write',
});
if (!allowed) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = { authorize };
Use it in a route:
// routes/reports.js
const { authorize } = require('../middleware/authorize');
const Report = require('../models/report');
router.get(
'/reports/:id',
authenticate, // verify JWT
authorize('reports/allow', async (req) => {
const report = await Report.findById(req.params.id);
return {
department_id: report.department_id,
region: report.region,
status: report.status,
};
}),
async (req, res) => {
const report = await Report.findById(req.params.id);
res.json(report);
}
);
Step 4: Test Policies Before Deploying
OPA has a built-in testing framework — this is one reason I chose it over Casbin:
# policies/reports_test.rego
package reports
test_auditor_can_read_audit_report if {
allow with input as {
"user": {"role": "auditor"},
"resource": {"status": "under_audit"},
"action": "read"
}
}
test_dept_manager_cannot_read_other_dept if {
not allow with input as {
"user": {"role": "manager", "level": "department", "department_id": "D1"},
"resource": {"department_id": "D2", "status": "active"},
"action": "read"
}
}
# Run tests
docker run --rm -v $(pwd)/policies:/policies \
openpolicyagent/opa:latest test /policies -v
Every time you change a policy, run the test suite before pushing. Integrating this into CI/CD takes about 15 minutes.
Things to Watch Out for in Production
Latency: Every request adds one HTTP call to OPA (~1-5ms on the same network). For latency-sensitive requests, use OPA bundle caching or run OPA as a library within your process (SDKs available for Go, Python, and Wasm).
OPA is a single point of failure: Run at least 2 OPA replicas, configure health checks, and make sure your middleware is fail-closed (deny if OPA is unreachable) rather than fail-open.
Audit log: OPA supports decision logging — every allow/deny decision is recorded along with its input. This is invaluable for compliance and debugging.
In our most recent web app project with 5 developers, applying this approach made a noticeable difference in productivity. Specifically, when the business requested an authorization change, instead of reading through 600 lines of middleware code, we just edited the .rego file, ran the tests, and pushed. No need to understand the internals of each service. Policy writers and feature developers can work completely independently.
When to Use OPA — and When Not To
OPA is a good fit when: you have many services, policies are complex and change frequently, you need an audit trail, and multiple people need to manage policies together.
If your app only has 2-3 simple roles and no expansion plans, RBAC with JWT claims is sufficient — don’t over-engineer it. OPA adds a dependency to your stack, and that dependency needs to be operated, monitored, and scaled alongside your system.
But if you’re currently debugging a 600-line middleware file packed with nested if-else statements… maybe it’s time to reconsider.
