HATEOAS Security — Securing Hypermedia APIs with Auth, Audit, and Link Filtering
In this tutorial, you will learn about HATEOAS Security. We cover key concepts, practical examples, and best practices to help you master this topic.
HATEOAS security focuses on filtering visible links based on user permissions, preventing unauthorized actions by only showing links the user can execute, and auditing which links clients follow through workflows.
What You'll Learn
- Permission-based link filtering
- Preventing unauthorized actions
- Securing link discovery
- Audit logging of link navigation
- CSRF protection for action links
- Rate Limiting hypermedia traversal
Why It Matters
In HATEOAS, the set of visible links IS the permission model. If a user can see a link, they can follow it. If they can't, the link is simply not shown. This makes authorization transparent and intuitive. DodaTech's Durga Antivirus Pro filters links by user role and device ownership, ensuring users only see actions they're authorized to perform.
Real-World Use
An admin sees quarantine, delete, and analyze links on a device. A support agent sees only analyze and report links. A viewer sees only the self link. The API doesn't return unauthorized links at all — no filtering needed on the client side.
flowchart TB
A["Authenticate User"] --> B["Get User Roles"]
B --> C["Load Resource"]
C --> D["Filter Links by Role"]
D --> E{"User Role?"}
E -->|Admin| F["All links: quarantine,\ndelete, analyze, config"]
E -->|Analyst| G["Limited links:\nanalyze, report"]
E -->|Viewer| H["Read-only links:\nself, threats"]
style E fill:#fef3c7,stroke:#d97706
Code Examples
Example 1: Permission-Based Link Filtering
// Permission definitions
const permissions = {
'dt:quarantine': { roles: ['admin'], devices: 'owned' },
'dt:delete': { roles: ['admin'], devices: 'any' },
'dt:analyze': { roles: ['admin', 'analyst'], devices: 'any' },
'dt:report': { roles: ['admin', 'analyst', 'viewer'], devices: 'any' },
'dt:config': { roles: ['admin'], devices: 'owned' },
'dt:remediate': { roles: ['admin', 'analyst'], devices: 'any' },
};
function filterLinksByPermission(links, user, device) {
const allowedLinks = {};
for (const [rel, link] of Object.entries(links)) {
if (rel === 'self' || rel === 'curies') {
allowedLinks[rel] = link;
continue;
}
const permission = permissions[rel];
if (!permission) {
// Unknown relation - allow only for admin
if (user.roles.includes('admin')) {
allowedLinks[rel] = link;
}
continue;
}
// Check role
const hasRole = permission.roles.some(r => user.roles.includes(r));
if (!hasRole) continue;
// Check device ownership
if (permission.devices === 'owned') {
if (device.ownerId !== user.id) continue;
}
allowedLinks[rel] = link;
}
return allowedLinks;
}
// Express middleware for link filtering
function linkSecurityMiddleware(req, res, next) {
const originalJson = res.json.bind(res);
res.json = function(body) {
if (body && body._links) {
const user = req.user;
const device = body; // or fetch from context
body._links = filterLinksByPermission(body._links, user, device);
}
return originalJson(body);
};
next();
}
app.use(linkSecurityMiddleware);
Example 2: Role-Based Link Configuration
from dataclasses import dataclass
from typing import List, Dict, Callable
import re
@dataclass
class LinkPermission:
allowed_roles: List[str]
requires_ownership: bool = False
rate_limit: int = 0 # requests per minute
audit: bool = True
class HypermediaSecurity:
def __init__(self):
self.link_permissions: Dict[str, LinkPermission] = {
"self": LinkPermission(allowed_roles=["*"]),
"collection": LinkPermission(allowed_roles=["*"]),
"dt:quarantine": LinkPermission(
allowed_roles=["admin", "security_lead"],
requires_ownership=False,
rate_limit=10,
audit=True,
),
"dt:analyze": LinkPermission(
allowed_roles=["admin", "analyst", "security_lead"],
rate_limit=30,
audit=True,
),
"dt:report": LinkPermission(
allowed_roles=["admin", "analyst", "viewer", "security_lead"],
audit=False,
),
"dt:delete": LinkPermission(
allowed_roles=["admin"],
requires_ownership=True,
rate_limit=5,
audit=True,
),
}
def get_visible_links(self, user, resource):
"""Return only links the user can access."""
visible = {}
for rel, permission in self.link_permissions.items():
# Check role
if "*" not in permission.allowed_roles:
if not any(r in user.roles for r in permission.allowed_roles):
continue
# Check ownership
if permission.requires_ownership:
if resource.get("owner_id") != user.id:
# Unless user is admin
if "admin" not in user.roles:
continue
# Build the link
if rel == "self":
href = f"/{resource['type']}s/{resource['id']}"
elif rel == "collection":
href = f"/{resource['type']}s"
else:
href = f"/{resource['type']}s/{resource['id']}/{rel.replace('dt:', '')}"
link = {"href": href}
# Add rate limit info
if permission.rate_limit:
usage = self.get_rate_limit_usage(user.id, rel)
remaining = max(0, permission.rate_limit - usage)
link["rate_limit"] = {
"limit": permission.rate_limit,
"remaining": remaining,
}
visible[rel] = link
return visible
def audit_link_follow(self, user, rel, resource):
"""Log when a user follows a link."""
permission = self.link_permissions.get(rel)
if permission and permission.audit:
print(f"AUDIT: User {user.id} followed {rel} on {resource['id']}")
# Usage in Flask
@app.route("/devices/<device_id>")
@login_required
def get_device(device_id):
device = get_device(device_id)
security = HypermediaSecurity()
device["_links"] = security.get_visible_links(g.user, device)
return jsonify(device)
Example 3: CSRF and Rate Limiting for Actions
const express = require('express');
const rateLimit = require('express-rate-limit');
const csrf = require('csurf');
const app = express();
// Rate limiter for hypermedia actions
const actionRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: (req) => {
// Different limits per action
const actionLimits = {
'quarantine': 10,
'analyze': 30,
'delete': 5,
};
const action = req.params.action;
return actionLimits[action] || 20;
},
message: {
type: 'https://errors.dodatech.com/rate-limited',
title: 'Rate Limit Exceeded',
status: 429,
_links: {
retryAfter: { href: '/devices/{id}', title: 'Retry after rate limit resets' },
},
},
});
// CSRF protection for state-changing actions
const csrfProtection = csrf({ cookie: true });
// Apply to all action endpoints
app.post('/devices/:id/:action',
csrfProtection,
actionRateLimiter,
async (req, res) => {
const device = getDevice(req.params.id);
const action = req.params.action;
// Check if action is in allowed links
const allowedLinks = filterLinksByPermission(
getDefaultLinks(device),
req.user,
device,
);
const actionRel = `dt:${action}`;
if (!allowedLinks[actionRel]) {
return res.status(403).json({
type: 'https://errors.dodatech.com/action-not-allowed',
title: 'Action Not Available',
status: 403,
detail: `Action '${action}' is not available in the current state`,
_links: {
self: { href: `/devices/${device.id}` },
},
});
}
// Audit log
auditLog('follow_link', {
user: req.user.id,
action: actionRel,
device: device.id,
ip: req.ip,
});
const result = await executeAction(device, action, req.body);
res.json(result);
}
);
Common Mistakes
- Returning all links and relying on client-side hiding — if the API returns a quarantined link, a malicious client can still call it even if the UI doesn't show it. Filter links server-side.
- Not auditing link navigation — hypermedia workflows create complex navigation paths. Audit which links users follow to detect suspicious patterns.
- Rate limiting the root endpoint — rate limit specific action links instead. The root endpoint is just navigation; actions are the expensive operations.
- Forgetting CSRF for action links — action links modify state. Protect them with CSRF tokens even though clients discover them via hypermedia.
- Showing links the user can't use — if a link requires a permission the user doesn't have, don't show it. Showing unavailable links frustrates users and creates security confusion.
Practice Questions
- How does link filtering enforce authorization in HATEOAS?
- Why must link filtering happen server-side, not client-side?
- How do you audit which links users follow?
- What rate limiting Strategy works best for hypermedia actions?
- How do you handle CSRF with dynamically discovered action URLs?
Challenge: Design a security model for a HATEOAS threat management API with 5 user roles (admin, security-lead, analyst, viewer, auditor). Define which links each role can see, which require device ownership, and rate limits per action.
Mini Project
Build a security layer for a HATEOAS API with: permission-based link filtering per user role, ownership validation for device-specific actions, rate limiting per action type with remaining quota in link metadata, CSRF protection for all action links, and audit logging with correlation IDs for all link navigation.
FAQ
What's Next
Learn HATEOAS API design
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro