Safeguard
Application Security•

ASVS 5.0 V8.3.1: Enforce Authorization on a Trusted Layer

ASVS V8.3.1 (Level 1) says authorization must be enforced in a trusted service layer, never in client-side JavaScript. How to do it and how to prove it.

Safeguard Security Team
4 min read

ASVS 5.0 V8.3.1 requires that the application enforces authorization rules at a trusted service layer and does not depend on controls an untrusted consumer could manipulate, such as client-side JavaScript. It is a Level 1 requirement, so every application is expected to meet it. If hiding a button is your only protection for an action, this requirement fails.

Why it matters

Anything running in the browser belongs to the user. They can edit the DOM, replay requests in curl, flip a flag in local storage or call the API directly without ever loading your UI. A frontend check is a convenience that saves a round trip. It is not a control.

The classic finding goes like this: the admin menu is hidden for normal users, but POST /api/admin/users/42/disable works for anyone with a session. Another variant hides the field in the form while the API still accepts it.

How to implement it

Put the decision where the data is touched, on the server, and make it hard to forget. A route by route if statement will be missed eventually. Use middleware or a policy layer that denies by default.

// Express: deny by default, require an explicit policy per route
import type { Request, Response, NextFunction } from "express";

type Policy = (req: Request) => boolean | Promise<boolean>;

export const requires = (policy: Policy) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      if (!req.user) return res.sendStatus(401);
      if (!(await policy(req))) return res.sendStatus(403);
      next();
    } catch {
      res.sendStatus(403);
    }
  };

const isAdmin: Policy = (req) => req.user!.roles.includes("admin");
const ownsInvoice: Policy = async (req) => {
  const inv = await db.invoice.findById(req.params.id);
  return !!inv && inv.ownerId === req.user!.id;
};

app.post("/api/admin/users/:id/disable", requires(isAdmin), disableUser);
app.get("/api/invoices/:id", requires(ownsInvoice), getInvoice);

Details worth copying: the policy fails closed on any exception, the user identity comes from the verified session or token, and object ownership is checked against the database, not against a value sent by the client.

Return the same status for "does not exist" and "not yours" where existence itself is sensitive.

How to test it

  1. Log in as a low privilege user and capture a request made by an admin in the UI.
  2. Replay it with the low privilege session using curl or your proxy. Expect 403.
  3. Try with no session at all. Expect 401.
  4. Change object IDs in the path, query and body to values owned by another user.
  5. Turn off JavaScript or call the endpoints from a script. Every authorization outcome must be identical, because the UI plays no part.
  6. Inventory every route and confirm each has a policy. A route listing diffed against a policy map catches forgotten endpoints.

A dynamic scan finds many of these automatically. Try the DAST web app scan demo to see how missing access controls are reported.

Common mistakes

  • Relying on role claims stored in local storage or a cookie the user can edit without a signature.
  • Checking permission in a single "list" endpoint but not in the matching "get", "update" and "delete" endpoints.
  • Trusting hidden form fields or request headers such as X-User-Role.
  • Protecting the UI route in a single page app and forgetting the API behind it.
  • A catch block that calls next() on error.

Try it yourself

Click through it at your own pace, or open it full screen.

Quick check

The admin button is hidden from normal users. Is the action protected?

Only if the server also rejects the request from a normal user. Hiding the button is cosmetic.

Where should the user's role come from when a request arrives?

From the server side session or a verified token, never from an unsigned value the client sends.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.