Authorization Bypass Through User-Controlled Key
Description
Authorization Bypass Through User-Controlled Key occurs when the system's authorization functionality does not prevent one user from accessing another user's data by modifying the key value identifying the data. This is commonly known as Insecure Direct Object Reference (IDOR). The vulnerability arises when applications use user-supplied input (such as IDs, filenames, or keys) to directly access objects without verifying the user's authorization to access that specific object. Attackers simply modify these references to access unauthorized resources.
Risk
IDOR vulnerabilities are extremely common and often critical. They can expose sensitive data of other users, enable unauthorized modifications, and lead to complete data breaches. Because exploitation is trivial (just changing an ID), automated tools can quickly enumerate and extract large amounts of data. Notable breaches affecting millions of users have resulted from IDOR vulnerabilities. The First American Financial exposure of 885 million records was caused by sequential document IDs without authorization checks.
Solution
Always verify that the authenticated user has permission to access the requested resource. Implement object-level authorization checks. Use indirect references that map to actual objects server-side. Consider using UUIDs instead of sequential IDs to reduce predictability (but still implement authorization). Implement proper access control lists. Log and monitor access patterns for anomalies. Test authorization on every access path to sensitive resources.
Common Consequences
| Impact | Details |
|---|---|
| Confidentiality | Scope: Data Exposure Unauthorized access to other users' personal data, documents, and records. |
| Integrity | Scope: Data Modification Ability to modify or delete other users' data by manipulating object references. |
| Privacy | Scope: Privacy Violation Access to private communications, medical records, financial data of other users. |
Example Code + Solution Code
Vulnerable Code
# VULNERABLE: Direct object reference without authorization
@app.route('/api/users/<user_id>/profile')
@login_required
def get_profile(user_id):
# Any authenticated user can access any profile!
user = User.query.get(user_id)
return jsonify(user.to_dict())
# VULNERABLE: Document access by ID
@app.route('/api/documents/<doc_id>')
@login_required
def get_document(doc_id):
# Just incrementing doc_id exposes all documents
document = Document.query.get(doc_id)
return send_file(document.path)
# VULNERABLE: Order details
@app.route('/api/orders/<order_id>')
@login_required
def get_order(order_id):
# Attacker changes order_id to view other users' orders
order = Order.query.get(order_id)
return jsonify({
'items': order.items,
'total': order.total,
'shipping_address': order.shipping_address, # PII exposed!
'payment_method': order.payment_method
})
# VULNERABLE: File download
@app.route('/download')
@login_required
def download_file():
filename = request.args.get('file')
# Attacker: ?file=../../../etc/passwd or ?file=user_123_data.pdf
return send_from_directory('/uploads', filename)
// VULNERABLE: REST endpoint with IDOR
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/users/{userId}/messages")
public List<Message> getMessages(@PathVariable Long userId) {
// No check if authenticated user is userId!
return messageRepository.findByUserId(userId);
}
@DeleteMapping("/invoices/{invoiceId}")
public ResponseEntity<?> deleteInvoice(@PathVariable Long invoiceId) {
// Any user can delete any invoice
invoiceRepository.deleteById(invoiceId);
return ResponseEntity.ok().build();
}
@PutMapping("/accounts/{accountId}")
public ResponseEntity<?> updateAccount(
@PathVariable Long accountId,
@RequestBody AccountUpdate update) {
// Attacker modifies any account
Account account = accountRepository.findById(accountId).orElseThrow();
account.setEmail(update.getEmail());
accountRepository.save(account);
return ResponseEntity.ok().build();
}
}
// VULNERABLE: Express API with IDOR
app.get('/api/users/:userId/data', authenticate, async (req, res) => {
// userId from URL, not validated against authenticated user
const data = await UserData.findOne({ userId: req.params.userId });
res.json(data); // Returns any user's data
});
// VULNERABLE: GraphQL query
const resolvers = {
Query: {
user: (_, { id }) => {
// No authorization check
return User.findById(id);
},
invoice: (_, { id }) => {
// Anyone can query any invoice
return Invoice.findById(id);
}
}
};
// VULNERABLE: File serving
app.get('/files/:fileId', authenticate, async (req, res) => {
const file = await File.findById(req.params.fileId);
// No ownership check
res.sendFile(file.path);
});
Fixed Code
# SAFE: Verify ownership before access
@app.route('/api/users/<user_id>/profile')
@login_required
def get_profile(user_id):
# Verify user is accessing their own profile or is admin
if str(current_user.id) != user_id and not current_user.is_admin:
abort(403, 'Access denied')
user = User.query.get_or_404(user_id)
return jsonify(user.to_dict())
# SAFE: Check document ownership
@app.route('/api/documents/<doc_id>')
@login_required
def get_document(doc_id):
document = Document.query.get_or_404(doc_id)
# Verify ownership or sharing permission
if not document.can_access(current_user):
abort(403, 'Access denied')
return send_file(document.path)
# SAFE: Filter orders by authenticated user
@app.route('/api/orders/<order_id>')
@login_required
def get_order(order_id):
# Query includes user constraint
order = Order.query.filter_by(
id=order_id,
user_id=current_user.id
).first_or_404()
return jsonify(order.to_dict())
# SAFE: Use indirect references
ALLOWED_DOWNLOADS = {
'report': 'monthly_report.pdf',
'invoice': 'invoice_template.pdf',
'guide': 'user_guide.pdf'
}
@app.route('/download/<file_key>')
@login_required
def download_file(file_key):
filename = ALLOWED_DOWNLOADS.get(file_key)
if not filename:
abort(404)
# User-specific files
user_path = f'/uploads/{current_user.id}'
return send_from_directory(user_path, filename)
# SAFE: Object-level authorization decorator
def authorize_resource(model, id_param='id', owner_field='user_id'):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
resource_id = kwargs.get(id_param)
resource = model.query.get_or_404(resource_id)
if getattr(resource, owner_field) != current_user.id:
if not current_user.is_admin:
abort(403)
kwargs['resource'] = resource
return f(*args, **kwargs)
return wrapper
return decorator
@app.route('/api/posts/<post_id>')
@login_required
@authorize_resource(Post, 'post_id', 'author_id')
def get_post(post_id, resource):
return jsonify(resource.to_dict())
// SAFE: Service layer authorization
@Service
public class SecureUserService {
@Autowired
private MessageRepository messageRepository;
public List<Message> getMessages(Long userId, Authentication auth) {
User currentUser = (User) auth.getPrincipal();
// Verify authorization
if (!currentUser.getId().equals(userId) &&
!currentUser.hasRole("ADMIN")) {
throw new AccessDeniedException("Cannot access other user's messages");
}
return messageRepository.findByUserId(userId);
}
}
@RestController
@RequestMapping("/api")
public class SecureController {
@Autowired
private SecureUserService userService;
@GetMapping("/users/{userId}/messages")
public List<Message> getMessages(
@PathVariable Long userId,
Authentication auth) {
return userService.getMessages(userId, auth);
}
@DeleteMapping("/invoices/{invoiceId}")
@PreAuthorize("@invoiceSecurityService.canDelete(#invoiceId, authentication)")
public ResponseEntity<?> deleteInvoice(@PathVariable Long invoiceId) {
invoiceRepository.deleteById(invoiceId);
return ResponseEntity.ok().build();
}
}
@Service
public class InvoiceSecurityService {
@Autowired
private InvoiceRepository invoiceRepository;
public boolean canDelete(Long invoiceId, Authentication auth) {
User user = (User) auth.getPrincipal();
Invoice invoice = invoiceRepository.findById(invoiceId).orElse(null);
if (invoice == null) return false;
// Owner or admin can delete
return invoice.getUserId().equals(user.getId()) ||
user.hasRole("ADMIN");
}
}
// SAFE: Authorization middleware
const authorizeResource = (model, ownerField = 'userId') => {
return async (req, res, next) => {
const resourceId = req.params.id || req.params[`${model.modelName.toLowerCase()}Id`];
const resource = await model.findById(resourceId);
if (!resource) {
return res.status(404).json({ error: 'Not found' });
}
// Check ownership
if (resource[ownerField].toString() !== req.user.id &&
!req.user.roles.includes('admin')) {
return res.status(403).json({ error: 'Access denied' });
}
req.resource = resource;
next();
};
};
// Usage
app.get('/api/users/:id/data',
authenticate,
authorizeResource(UserData),
async (req, res) => {
res.json(req.resource);
}
);
// SAFE: GraphQL with authorization
const resolvers = {
Query: {
user: async (_, { id }, context) => {
if (!context.user) {
throw new AuthenticationError('Not authenticated');
}
// Can only query own user or if admin
if (context.user.id !== id && !context.user.isAdmin) {
throw new ForbiddenError('Access denied');
}
return User.findById(id);
},
invoice: async (_, { id }, context) => {
const invoice = await Invoice.findById(id);
if (!invoice) {
throw new NotFoundError('Invoice not found');
}
if (invoice.userId !== context.user.id && !context.user.isAdmin) {
throw new ForbiddenError('Access denied');
}
return invoice;
}
}
};
// SAFE: File access with ownership
app.get('/files/:fileId', authenticate, async (req, res) => {
const file = await File.findById(req.params.fileId);
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
// Check ownership or sharing
if (file.ownerId !== req.user.id &&
!file.sharedWith.includes(req.user.id)) {
return res.status(403).json({ error: 'Access denied' });
}
res.sendFile(file.path);
});
Exploited in the Wild
First American Financial (2019)
885 million documents containing sensitive personal and financial data were exposed due to sequential document IDs without authorization checks. Anyone could access any document by incrementing the ID.
Parler Data Scrape (2021)
Parler's API used sequential post IDs without authorization, allowing researchers to download the entire platform's content including deleted posts and user data.
Facebook Photo Exposure (2018)
A bug allowed apps to access photos that users uploaded but never shared, affecting 6.8 million users, due to improper authorization on photo IDs.
Tools to test/exploit
-
Burp Suite — intercept and modify ID parameters.
-
Autorize — Burp extension for IDOR testing.
-
OWASP ZAP — automated IDOR detection.
-
ffuf — ID enumeration and fuzzing.
CVE Examples
-
CVE-2021-39165 — Cachet IDOR vulnerability.
-
CVE-2020-36193 — Archive_Tar IDOR.
-
CVE-2019-12164 — Zoho ManageEngine IDOR.
References
-
MITRE. "CWE-639: Authorization Bypass Through User-Controlled Key." https://cwe.mitre.org/data/definitions/639.html
-
OWASP. "Insecure Direct Object References." https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/05-Authorization_Testing/04-Testing_for_Insecure_Direct_Object_References