Skip to content

HATEOAS Security — Securing Hypermedia APIs with Auth, Audit, and Link Filtering

DodaTech Updated 2026-06-28 6 min read

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

// 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);
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

  1. 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.
  2. Not auditing link navigation — hypermedia workflows create complex navigation paths. Audit which links users follow to detect suspicious patterns.
  3. Rate limiting the root endpoint — rate limit specific action links instead. The root endpoint is just navigation; actions are the expensive operations.
  4. Forgetting CSRF for action links — action links modify state. Protect them with CSRF tokens even though clients discover them via hypermedia.
  5. 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

  1. How does link filtering enforce authorization in HATEOAS?
  2. Why must link filtering happen server-side, not client-side?
  3. How do you audit which links users follow?
  4. What rate limiting Strategy works best for hypermedia actions?
  5. 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

Is link filtering sufficient for authorization?

Link filtering controls which actions users can discover, but the server must still authorize every request independently. Defense in depth: don't show the link AND reject unauthorized requests.

How do I prevent link enumeration?

Don't include IDs in error messages for unauthorized resources. Return 404 instead of 403 for resources the user shouldn't know exist, matching HATEOAS where unlinked resources are unreachable.

Should I expose rate limits in link metadata?

Yes. Include remaining rate limit in action links so clients can show warnings before the limit is hit. This prevents surprise 429 responses.

How do I handle CSRF with dynamic URLs?

Include a CSRF token in each action link as a query parameter or require it in the request body. The client discovers the action URL and includes the token when executing.

What audit information should I log?

Log: user ID, timestamp, link relation followed, resource ID, IP address, user agent, and whether the action succeeded.

What's Next

Learn HATEOAS API design

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro