I first ran into access control while watching a SOC analyst course on YouTube. The instructor mentioned IAM — Identity and Access Management — in passing, and I realized I had never actually thought about who gets to do what in the systems I build. I knew how to log someone in. I knew how to check if a user was an "admin" before showing a dashboard. But I had never stepped back and asked whether the entire flow of permissions across my application made sense.
So I searched Google for how to audit access control and found a formal seven-step process: define objectives, review policies, assess permissions, test authentication, identify vulnerabilities, document findings, and monitor continuously. It sounded thorough, but reading it and doing it are different things. When I tried to apply it to my own projects, I got stuck on the first step. I couldn't even list every resource that needed protection. I didn't know all the API endpoints I had exposed, I had forgotten about old service accounts, and I had users with roles that didn't match their actual jobs. That's when I realized access control isn't a policy document. It's a living map of every door in your system and who has a key to each one.
This isn't a compliance checklist. I'm walking through exactly how I audit access control in practice — how I map the full scope of resources and roles, verify that permissions match real job duties, test whether least privilege is actually enforced, attempt privilege escalation to find gaps, and read access logs for the story they tell. I'll also touch on whether this can be automated, and what AI actually brings to the table versus what still requires human judgment.
What Access Control Actually Is
At its core, access control is the set of rules that decides who can touch what. It's not just login and logout. It's the entire flow from authentication — proving who you are — through authorization — deciding what you're allowed to do — to enforcement — actually blocking the actions you aren't allowed to take. A resource is anything worth protecting: a database table, an API endpoint, a file, a dashboard, a configuration setting. A role is a collection of permissions grouped by job function. A permission is a specific allowed action on a specific resource, like "read users" or "delete orders."
When access control breaks, it usually breaks in one of three ways. First, excessive permissions — someone has more access than they need. Second, stale access — someone still has permissions after their role changed or they left. Third, missing enforcement — the rules exist on paper but the application doesn't actually check them at the critical boundary. My audit process is designed to catch all three.
How I Audit It
I distilled the formal seven-step process into five practical actions I can actually execute on a real codebase. Here's what I do.
Step 1: Map the Scope
Before I check a single permission, I inventory everything that needs protection. I list all API endpoints, database tables, admin panels, file storage buckets, background job queues, and third-party integrations. I also list every user role, service account, and integration token. If I don't know a resource exists, I can't protect it. This step is tedious and often reveals forgotten endpoints, old test accounts, and services running with credentials no one remembers creating.
Step 2: Review Role-Based Access
I compare every user's assigned role against their actual job duties. A developer who moved to a different team six months ago might still have production database access. An intern's test account might still be active. I look for role explosion — when an organization creates too many custom roles that overlap and become impossible to reason about. I also check for the opposite: too few roles, where everyone is either "user" or "admin" and there's no middle ground. A service account is a non-human account used by applications or scripts to authenticate with other services.
SELECT u.id, u.email, u.role, u.last_login, u.created_at
FROM users u
WHERE u.role = 'admin'
AND u.last_login < NOW() - INTERVAL '90 days'
ORDER BY u.last_login;
Step 3: Test Least Privilege
The principle of least privilege means every user and service should hold only the minimum permissions needed for their specific tasks. I verify this by picking representative accounts from each role and tracing every action they can perform. If a "support" user can export the entire user database, that's not least privilege. If a microservice token can delete tables when it only needs to read them, that's a problem. I document every over-permissioned account and queue it for remediation.
Step 4: Attempt Privilege Escalation
This is the most revealing step. I put on an attacker's hat and try to climb from a low-privilege account to a high-privilege one. I test whether a standard user can access administrative pages by guessing URLs, manipulating request parameters, or replaying tokens. I check if role checks happen on the client side but not the server. I look for IDOR vulnerabilities — Insecure Direct Object Reference — where changing a numeric ID in a URL lets me access another user's data. If I can escalate privileges without exploiting a complex bug, the access control flow is broken.
Client-side role checks are decorative. If your React component hides an admin button but your API doesn't verify the role server-side, the access control doesn't exist. I always test the API directly with a low-privilege token.
Step 5: Analyze Access Logs
Logs are where the truth lives. I check login histories for unusual patterns — logins from new countries, off-hours access, or rapid successive failures followed by a success. I look for permission changes: who granted admin rights, when, and from where. I correlate failed access attempts with the resources they targeted. A spike in 403 errors from a single account might mean someone is probing for gaps. A single successful admin action from a regular user account might mean escalation already happened.
Can AI Automate This?
After running this process manually a few times, I started wondering whether AI could speed it up. The answer is: partially. AI and multi-agent systems are already good at specific pieces of the puzzle.
Log analysis is the strongest fit. Machine learning models can baseline normal access patterns and flag anomalies — unusual login times, impossible travel, or sudden spikes in data access — faster than any human scrolling through rows. Permission review can be partially automated by comparing role assignments against HR data or ticketing systems to detect stale access. Vulnerability scanning tools can already test for IDOR and missing authorization checks at scale.
But the parts that matter most — understanding business context, judging whether a permission is excessive for a specific person's duties, and interpreting the intent behind a suspicious log pattern — still need human judgment. AI can tell me that an account accessed 10,000 user records at 3 AM. It can't tell me whether that was a legitimate data migration or a breach without asking the team. I treat AI as a force multiplier for the tedious steps, not a replacement for the audit itself.
Automation finds patterns; humans find meaning. Use tools to surface anomalies, but always validate them against business context before calling something a vulnerability.
Production Checklist
Access control auditing isn't a one-time project. These are the four rules I hold every system to:
- Inventory every resource, API, and account before checking permissions — you can't audit what you don't know exists
- Verify least privilege by tracing what each role can actually do, not just what the policy document says
- Always test privilege escalation from the attacker's perspective; client-side checks alone mean nothing
- Correlate access logs with business context — automation surfaces anomalies, but humans determine intent
None of this makes a system unhackable, and I don't treat it that way. Access control was just another layer I learned to inspect properly. XSS, SQL injection, insecure deserialization, and supply chain attacks are all still sitting on the same list. Security is a process I keep working through, not a milestone I hit once and move past.
IDOR in APIs
Insecure Direct Object Reference is the access control flaw I find most often in REST APIs. The idea is simple: the API accepts an ID parameter, looks up the resource, and returns it — without checking whether the requesting user actually owns or has permission to access that resource. Change the ID, get someone else's data.
The classic example: a user profile endpoint at GET /api/users/1042/profile. After logging in, I notice my user ID is 1042. I change it to 1043 and the API returns another user's full profile — email, phone number, address, whatever's stored. No error, no challenge. The server just serves the data because the endpoint never verified that user 1042 is requesting user 1042's profile.
// VULNERABLE: fetches any user by ID without ownership check
app.get('/api/users/:id/profile', authenticate, async (req, res) => {
const user = await db.users.findById(req.params.id);
return res.json(user);
});
// SECURE: enforces ownership — only the requesting user gets their own data
app.get('/api/users/:id/profile', authenticate, async (req, res) => {
if (req.user.id !== req.params.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await db.users.findById(req.params.id);
return res.json(user);
});
IDOR in GraphQL
GraphQL makes IDOR worse because the client controls the shape of the query. A resolver that returns a User object by ID without ownership checks is identically vulnerable, but harder to spot during a code review because the authorization logic is scattered across resolvers rather than centralized in route middleware. I audit every resolver that accepts an ID argument and verify the ownership check is present, not assumed.
Sequential integer IDs make IDOR trivially exploitable. Using UUIDs instead of auto-increment IDs doesn't fix the authorization problem, but it makes mass enumeration significantly harder — an attacker can't just iterate from 1 to 10,000.
Horizontal vs Vertical Privilege Escalation
Not all privilege escalation is the same, and the distinction matters for how you test and defend against it.
Horizontal privilege escalation happens when a user accesses another user's resources at the same privilege level. User A accessing User B's orders, messages, or profile is horizontal escalation. The attacker doesn't gain more permissions — they just reach sideways into data that belongs to someone else. IDOR is the primary mechanism.
Vertical privilege escalation happens when a low-privilege user gains higher-privilege access — a regular user accessing admin functionality, a customer reading internal reports, a support agent executing admin actions. This typically exploits missing server-side role checks, parameter manipulation (sending role=admin in a request), or broken function-level access control where admin endpoints exist but are only hidden rather than protected.
Testing Both Types
For horizontal escalation: log in as User A, collect resource IDs (order IDs, message IDs, document IDs), log out, log in as User B, and attempt to access those IDs directly. For vertical escalation: log in as a low-privilege user, collect all API endpoints (from the frontend JavaScript, network tab, or documentation), and attempt to call admin endpoints with the low-privilege token. A 403 response means the check is there. A 200 means it isn't.
Key insight: The most valuable tool for testing access control is two browser sessions open simultaneously — one as a normal user, one as an attacker. Test every action from the normal user session and verify it can't be replicated from the attacker session.
Role-Based vs Attribute-Based Access Control
When I design access control from scratch, I choose between two main models depending on how complex the permission requirements are.
RBAC (Role-Based Access Control) assigns permissions to roles, and roles to users. User Alice has role "editor", which has permissions "create_post" and "edit_post" but not "delete_post". Simple, auditable, easy to reason about. RBAC works well when your permission requirements are stable and map cleanly to job functions. It breaks down when you need fine-grained, context-dependent rules — like "Alice can edit posts, but only her own posts, and only when their status is 'draft'."
ABAC (Attribute-Based Access Control) evaluates policies against attributes of the user, the resource, and the environment. A policy might say: "allow edit if user.department == resource.department AND resource.status == 'draft' AND time.hour BETWEEN 9 AND 17." ABAC is far more expressive but also far harder to audit. When I can't explain who has access to what in a single spreadsheet, I probably need ABAC. When I can, RBAC is the right default.
Mass Assignment Vulnerabilities
Mass assignment is a vulnerability where a framework automatically binds HTTP request parameters to model attributes — including ones the developer never intended to be settable by users. It's a common pattern in Rails, Laravel, and Django, and it's bitten major applications in the past.
The famous example: GitHub's 2012 vulnerability where an attacker added their SSH key to any repository, including the Rails organization's own repo, using a mass assignment flaw. The attacker simply included public_key[user][role]=owner in a request, and the framework dutifully applied it because the role attribute wasn't explicitly protected.
# VULNERABLE: blind model update from request data
def update_profile(request):
user = request.user
user.__dict__.update(request.POST) # Never do this
user.save()
# SECURE: explicit allowlist of updatable fields
ALLOWED_PROFILE_FIELDS = ['display_name', 'bio', 'avatar_url']
def update_profile(request):
user = request.user
for field in ALLOWED_PROFILE_FIELDS:
if field in request.POST:
setattr(user, field, request.POST[field])
user.save()
In Rails, use permit in strong parameters. In Laravel, use $fillable on models. In Django, use explicit form fields or serializer fields with read_only=True for sensitive attributes. The rule is always the same: define what can be set by users, not what can't.
Testing Methodology
My access control audit process is systematic. I don't trust that things are implemented correctly — I verify them. Here's how I go through every endpoint.
Build an Endpoint Map First
Before testing anything, I map every API endpoint: the path, HTTP method, what role the UI assumes is needed, and what data it returns or modifies. I extract this from the frontend's network requests (using DevTools Network tab while navigating the app as each role), the OpenAPI/Swagger documentation if it exists, and the route files in the codebase. An endpoint I don't know exists is an endpoint I can't test.
Test Every Endpoint with Every Role
I maintain separate authentication tokens for each role: unauthenticated, regular user, premium user, support agent, admin. For every endpoint on my map, I send requests with each token and document the response code. A 200 from a non-admin token on an admin endpoint is a finding. A 403 confirms the check exists. I automate this with a simple script that replays a collected request list with different Authorization headers and logs the response codes.
#!/bin/bash
# Test admin endpoint with user token
ADMIN_ENDPOINT="https://api.example.com/admin/users"
USER_TOKEN="eyJhbGci..."
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $USER_TOKEN" \
"$ADMIN_ENDPOINT")
if [ "$STATUS" = "200" ]; then
echo "VULNERABILITY: Admin endpoint accessible with user token"
else
echo "OK: Returned $STATUS"
fi
