Entry 002 of the deV633 Security Lab. It picks up where entry 001 left off: the payslip with the edited ID, the hidden button, the admin route with no check.
In Security Lab #001, we started with a simple idea:
Developers usually ask, “Does this feature work?”
Security also asks, “How can this feature be abused?”
For Lab #002, we’ll look at one of the most common places where that abuse happens:
Authentication and authorization.
They sound similar.
They often appear together.
But they solve two completely different problems.
And confusing them can create serious security vulnerabilities.
The Simplest Difference
Authentication answers:
Who are you?
Authorization answers:
What are you allowed to do?
That sounds simple enough.
But in real applications, developers sometimes stop after authentication.
A user successfully logs in.
They receive a valid session or JWT.
The backend knows who they are.
And then we accidentally assume:
“They’re authenticated, so the request is okay.”
That assumption is dangerous.
A user can be completely legitimate and still attempt an action they are not allowed to perform.
A Simple Example
Imagine an HR system.
Alice logs in successfully.
The backend identifies her as:
User: Alice
Employee ID: 102
Role: EMPLOYEE
Alice then requests:
GET /api/employees/102/payslip
That makes sense.
She is requesting her own payslip.
Now Alice changes the request:
GET /api/employees/103/payslip
Suppose employee 103 belongs to Bob.
If the API returns Bob’s payslip, authentication technically worked.
The system correctly identified Alice.
But authorization failed.
The backend asked:
Is Alice logged in?
But it failed to ask:
Is Alice allowed to access employee 103?
That distinction is the entire point of Lab #002.
Authentication: Proving Identity
Authentication establishes identity.
Typical authentication mechanisms include:
Username + password
Session cookies
JWT
OAuth2 / OpenID Connect
API keys
Passkeys
Biometrics
MFA
A login flow might look like:
User
↓
Username + Password
↓
Identity Provider
↓
Credentials Valid?
↓
Authenticated Identity
↓
Session / Token
For example:
{
"sub": "user-123",
"name": "Alice",
"email": "alice@example.test"
}
The system now knows:
This request belongs to Alice.
Authentication is complete.
But we still have not answered what Alice can do.
Authorization: Controlling Access
Authorization happens after identity is established.
The system might ask:
Can Alice view payroll?
Can Alice approve leave?
Can Alice edit employee records?
Can Alice access branch B?
Can Alice manage users?
Can Alice delete this invoice?
Can Alice access this customer's data?
Authorization can depend on many factors:
Role
Permission
Ownership
Organization
Branch
Department
Resource
Subscription level
Location
Time
Workflow state
This is where application security becomes more interesting.
Role-Based Access Control
A common implementation is Role-Based Access Control or RBAC.
For example:
ADMIN
MANAGER
CASHIER
EMPLOYEE
Then permissions are attached to each role.
Example:
ADMIN
users:create
users:update
users:delete
payroll:view
payroll:process
MANAGER
employees:view
schedules:update
reports:view
EMPLOYEE
profile:view
payslip:view-own
Then your backend checks the role or permission before allowing the action.
For example:
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) {
...
}
This is better than checking only whether the user is authenticated.
But roles alone can still become too coarse.
Why Permissions Are Often Better Than Roles
Imagine someone has the role:
BRANCH_HR
What exactly does that mean?
Can they:
View employees?
Edit employees?
Approve overtime?
Run payroll?
View salaries?
Create users?
Access every branch?
The role name itself does not answer those questions.
A permission-based model can be more explicit.
Example:
employee:read
employee:update
attendance:approve
payroll:view
payroll:process
Then a user can have only the capabilities they actually need.
For example:
Branch HR
employee:read
employee:update
attendance:approve
But not:
payroll:process
user:delete
organization:update
This principle is called:
Least privilege
Give a user only the permissions necessary to perform their job.
Nothing more.
Authentication Happens Once. Authorization Happens Constantly.
This is an important mental model.
Authentication usually happens at login.
Authorization happens repeatedly throughout the system.
For example:
Login
↓
AUTHENTICATION
↓
Dashboard
↓
Can view reports?
↓
AUTHORIZATION
↓
Open employee
↓
Can view this employee?
↓
AUTHORIZATION
↓
Edit salary
↓
Can edit salary?
↓
AUTHORIZATION
↓
Approve payroll
↓
Can approve this payroll?
↓
AUTHORIZATION
A secure application checks authorization whenever a protected action occurs.
Hiding a Button Is Not Authorization
Imagine this Vue component:
<button v-if="user.role === 'ADMIN'">
Delete User
</button>
This is useful for user experience.
But it is not security.
Someone can bypass the frontend completely and send:
DELETE /api/users/123
using:
Postman
curl
Browser DevTools
Burp Suite
A custom script
Another frontend
If the backend does not validate authorization, the hidden button accomplished nothing.
The frontend should control presentation.
The backend must control authority.
A strong rule:
Never rely on the frontend to enforce security.
Authentication Token ≠ Authorization
JWTs are another place developers sometimes become confused.
Suppose the API receives:
Authorization: Bearer eyJ...
The token is:
valid
signed
not expired
issued by the correct identity provider
That proves the identity is legitimate.
It still does not mean every request should be accepted.
Think about this:
Valid JWT
≠
Permission to do everything
The correct flow is:
Token valid?
↓
Who is the user?
↓
What permissions do they have?
↓
Which resource are they accessing?
↓
Are they allowed to perform this action?
↓
Allow / Deny
Resource-Level Authorization
This is where many real vulnerabilities happen.
Suppose a SaaS platform has two organizations:
Tenant A
Tenant B
Alice belongs to Tenant A.
She requests:
GET /api/customers/100
Customer 100 belongs to Tenant A.
Allowed.
Then Alice changes the request:
GET /api/customers/200
Customer 200 belongs to Tenant B.
If the system checks only:
Does Alice have customer:read?
then it may accidentally return Tenant B’s customer.
The correct authorization rule is closer to:
Alice has customer:read
AND
Customer 200 belongs to an organization
Alice is authorized to access.
This is called resource-level authorization.
Multi-Tenant Systems Make Authorization More Important
Suppose your database contains:
customers
id | tenant_id | name
------------------------
1 | A | John
2 | A | Maria
3 | B | Robert
Alice belongs to tenant A.
She should never retrieve:
tenant_id = B
Even if she guesses the ID.
Bad query:
SELECT *
FROM customers
WHERE id = :id;
Better:
SELECT *
FROM customers
WHERE id = :id
AND tenant_id = :authorizedTenant;
Even better, enforce tenant isolation at multiple layers:
Request authorization
+
Service-level authorization
+
Repository filtering
+
Database isolation
Defense in depth again.
Ownership-Based Authorization
Not every rule is based on roles.
Sometimes access depends on ownership.
Consider:
DELETE /api/posts/123
A normal user may be allowed to delete posts.
But only their own posts.
The rule becomes:
user has post:delete
AND
post.owner_id == current_user.id
You could model this as:
CanDeletePost(user, post)
if user.isAdmin:
allow
else if user.id == post.ownerId:
allow
else:
deny
Authorization depends on both:
Who is asking?
+
What resource are they asking about?
Contextual Authorization
Sometimes authorization depends on more than user and resource.
For example:
A manager can approve overtime
BUT
not their own overtime.
Or:
Branch HR can edit employees
BUT
only employees assigned to their branch.
Or:
Payroll can be modified
BUT
only while status = DRAFT.
Now authorization becomes:
identity
+
permission
+
resource
+
relationship
+
workflow state
This is why security often becomes a domain-modeling problem rather than just a framework configuration problem.
Broken Access Control
When authorization fails, the broader vulnerability category is often called Broken Access Control.
Examples include:
Accessing another user's records
Calling admin endpoints as a normal user
Changing an ID in the URL
Viewing another tenant's data
Calling APIs hidden from the UI
Changing ownership fields
Performing actions outside your assigned branch
Accessing resources after permissions were revoked
One particularly common pattern is IDOR.
IDOR
IDOR means:
Insecure Direct Object Reference
Consider:
GET /api/invoices/1001
The developer expects a user to access invoice 1001.
But the attacker tries:
GET /api/invoices/1002
GET /api/invoices/1003
GET /api/invoices/1004
If the API returns invoices belonging to other users, organizations, or customers, authorization is broken.
The mistake is not using numeric IDs.
The mistake is:
The server trusts possession of an identifier as proof of authorization.
An ID identifies a resource.
It does not grant access to it.
UUIDs Do Not Fix Authorization
Developers sometimes respond to IDOR by replacing:
1001
with:
550e8400-e29b-41d4-a716-446655440000
UUIDs are harder to guess.
But they do not solve the authorization problem.
If Alice somehow obtains Bob’s UUID and the API does this:
SELECT *
FROM invoice
WHERE uuid = :uuid;
without checking ownership or tenant boundaries, the vulnerability still exists.
So:
Hard-to-guess IDs
≠
Authorization
Deny by Default
A strong security design principle is:
Deny unless explicitly allowed.
Avoid thinking:
Everyone can access it except these users.
Prefer:
Nobody can access it unless they satisfy the access rule.
Conceptually:
if (!authorized(user, resource, action)) {
throw new AccessDeniedException();
}
performAction();
Authorization failure should stop execution.
HTTP 401 vs 403
This distinction is useful for developers.
401 Unauthorized
Despite the name, HTTP 401 usually means:
You have not successfully authenticated.
Examples:
No token
Invalid token
Expired token
Invalid session
403 Forbidden
403 means:
We know who you are, but you are not allowed to do this.
Example:
Alice is authenticated.
Alice tries to delete a user.
Alice lacks user:delete.
→ 403 Forbidden
Easy mental model:
401 = Who are you?
403 = I know who you are, but no.
A Safer Spring Boot Example
Suppose we have:
@GetMapping("/employees/{id}")
public Employee getEmployee(@PathVariable Long id) {
return employeeService.getById(id);
}
If authentication is enabled, this might already require the user to log in.
But authorization is missing.
A better approach could include:
@PreAuthorize("hasAuthority('employee:read')")
@GetMapping("/employees/{id}")
public Employee getEmployee(@PathVariable Long id) {
return employeeService.getAuthorizedEmployee(id);
}
Then inside the service:
Employee employee = repository.findById(id)
.orElseThrow(NotFoundException::new);
if (!accessGuard.canAccess(currentUser, employee)) {
throw new AccessDeniedException("Forbidden");
}
return employee;
Now we have two checks:
Does the user have employee:read?
AND
Can this specific user access this specific employee?
That distinction matters.
Don’t Scatter Authorization Everywhere
There is another architectural lesson here.
Suppose you write authorization logic like this:
Controller A → check role
Controller B → check branch
Service C → check tenant
Controller D → forgot to check
Eventually someone forgets one.
That becomes a security bug.
Instead, create explicit access-control components.
For example:
EmployeeAccessGuard
PayrollAccessGuard
TenantAccessGuard
InventoryAccessGuard
Then authorization becomes part of the system design.
Example:
employeeAccessGuard.requireReadAccess(currentUser, employee);
This is easier to test, audit, and reason about.
Security Logic Is Business Logic
This is one of the most important lessons in this lab.
Consider:
Only the employee's manager can approve overtime.
Branch HR can correct attendance only for their assigned branches.
Payroll becomes immutable once finalized.
Cashiers cannot reopen another cashier's closed shift.
These are business rules.
They are also security rules.
That means security cannot always be delegated entirely to:
Keycloak
Spring Security
JWT
Cloudflare
Firewall
Those tools provide mechanisms.
Your application still needs to understand its domain.
The Principle of Least Privilege
Least privilege means:
Give users, services, and systems only the access they need.
Instead of:
ADMIN
everywhere, consider smaller capabilities:
inventory:read
inventory:adjust
inventory:approve
inventory:transfer
employee:read
employee:update
employee:terminate
payroll:view
payroll:prepare
payroll:approve
This allows different responsibilities without giving unnecessary authority.
It also reduces damage if an account becomes compromised.
Separation of Duties
Another useful concept is separation of duties.
Suppose one person can:
Create payroll
Approve payroll
Release payroll
Edit payroll afterward
That is convenient.
But from a control perspective, it may be dangerous.
A stronger workflow might be:
Payroll Staff
↓
Prepare Payroll
↓
HR Manager
↓
Review
↓
Authorized Approver
↓
Finalize
Security is not always:
Allow / Deny
Sometimes it is about designing safe workflows.
Authentication and Authorization in a Real Architecture
A typical system might look like:
USER
│
▼
┌──────────────┐
│ Identity │
│ Provider │
│ │
│ Keycloak │
│ Auth0 │
│ Entra ID │
└──────┬───────┘
│
Token
│
▼
┌──────────────┐
│ Backend API │
└──────┬───────┘
│
Validate identity
│
▼
Load permissions
│
▼
Evaluate resource
│
▼
Authorization rule
│
┌─────┴─────┐
▼ ▼
ALLOW DENY
The identity provider tells you who the user is.
Your application often still needs to decide what that identity means inside your domain.
A Small Lab
For this exercise, imagine we have two users.
Alice
id = 1
role = USER
and:
Bob
id = 2
role = USER
The endpoint is:
GET /api/users/{id}
Alice logs in.
Test:
GET /api/users/1
Expected:
200 OK
Now try:
GET /api/users/2
Ask:
Should Alice see Bob?
If yes, why?
If no, where is that rule enforced?
Now imagine an admin:
Charlie
id = 3
role = ADMIN
Should Charlie access both?
Maybe.
But even then, ask:
Does every ADMIN need this access?
Could a narrower permission exist?
Should the access be audited?
This simple exercise demonstrates that authorization is about policy, not merely code.
Test the Negative Cases
Developers naturally test successful flows.
Security testing requires negative cases.
Instead of only testing:
Admin can delete user.
also test:
Normal user cannot delete user.
Unauthenticated user cannot delete user.
User from tenant A cannot delete tenant B user.
Expired token cannot delete user.
User with read permission cannot delete user.
Former admin cannot delete user after privilege removal.
The negative tests are often more important.
A Good Authorization Test Matrix
For important endpoints, create a matrix.
Example:
| Actor | Read | Create | Update | Delete |
|---|---|---|---|---|
| Admin | ✅ | ✅ | ✅ | ✅ |
| Manager | ✅ | ✅ | ✅ | ❌ |
| Employee | Own only | ❌ | Own only | ❌ |
| Anonymous | ❌ | ❌ | ❌ | ❌ |
Then add resource boundaries:
Same tenant
Different tenant
Own branch
Different branch
Own resource
Other user's resource
This makes access rules visible instead of hidden inside scattered if statements.
Developer Checklist
Before shipping an endpoint, ask:
□ Does this endpoint require authentication?
□ What permission is required?
□ Does permission alone provide enough context?
□ Does ownership matter?
□ Does tenant membership matter?
□ Does branch or department matter?
□ Does workflow state matter?
□ Can the client manipulate the resource ID?
□ Can the frontend restriction be bypassed?
□ Is authorization enforced on the backend?
□ What happens if access is denied?
□ Is sensitive access logged?
□ Are negative authorization tests included?
□ What happens after a user's permission is revoked?
The Important Lesson
Authentication and authorization work together.
But they should never be confused.
Authentication says:
I know who this user is.
Authorization says:
I know exactly what this user is allowed to do.
A secure application needs both.
Because one of the most dangerous assumptions we can make is:
“The user is logged in, therefore we can trust the request.”
We cannot.
Authenticated users are still untrusted input.
Every sensitive action must be authorized.
Every protected resource must have an access rule.
And those rules should be enforced by the backend—not by the UI, not by obscurity, and not by assumptions.
Security Lab #002 Challenge
Take one system you have built.
Choose five endpoints.
For each endpoint, answer:
1. Who can call it?
2. What permission is required?
3. Does ownership matter?
4. Does tenant or branch membership matter?
5. What should happen if someone changes the resource ID?
6. What negative tests should exist?
Then create an authorization matrix.
You may discover that some of your existing endpoints rely more on assumptions than actual authorization.
That discovery is exactly what this lab is about.
Next
Security Lab #003 — Understanding HTTP Through a Security Lens
Before we go deeper into web vulnerabilities, we need to understand the protocol underneath almost every web attack and defense:
Requests
Responses
Headers
Cookies
Sessions
Tokens
Status codes
CORS
HTTPS
Because if you’re going to secure web applications, you need to understand what is actually moving across the wire.
Main takeaway:
Authentication proves identity. Authorization protects resources.
And in secure software, being logged in is only the beginning.