DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on

A Hidden Button Is Not an Authorisation Boundary

The most convincing authorisation gap can be a page that looks perfectly locked down.

An action disappears for callers who do not have the required permission. The screen feels safe, the happy path works, and a manual review confirms that the button is hidden. Yet the API behind that button may still accept any authenticated request.

That is the central lesson: UI visibility is a usability rule. Server-side authorisation is the security boundary.

Authentication answers only the first question

In ASP.NET Core, a controller-level Authorize attribute is useful. It establishes that a request must come from an authenticated principal. It does not, by itself, answer whether that principal may perform every operation exposed by the controller.

Those are different questions:

  • Authentication: who is calling?
  • Authorisation: may this caller perform this operation on this subject and scope?

The distinction becomes important when an API mixes operations with different risk profiles. Consider a generalised controller that supports:

  • reading my own record;
  • reading one record I am permitted to see;
  • listing an entire collection;
  • changing a relationship;
  • exporting a richer dataset.

All five actions may require authentication. They do not necessarily share one authorisation rule.

Why one controller-wide policy can be wrong

A recent committed hardening change illustrated this mixed-purpose shape. Some actions were legitimate self-service. Other actions worked across a collection or produced a higher-sensitivity export.

Moving one strict policy onto the entire controller would have been simple, but it would also have denied the self-service operations. Keeping only the baseline authentication rule would preserve those journeys, but leave the broader operations under-specified.

The design therefore needed operation-level decisions.

Here is a deliberately generic sketch:

[Authorize]
public sealed class RecordsController : ControllerBase
{
    [HttpGet("mine")]
    public Task<IActionResult> Mine() =>
        ReadForCurrentCaller();

    [HttpGet("all")]
    public async Task<IActionResult> All()
    {
        if (!access.CanReadCollection(User))
            return Forbid();

        return Ok(await records.All());
    }

    [HttpGet("export")]
    [Authorize(Policy = "Records.Export")]
    public Task<IActionResult> Export() =>
        ExportAuthorisedDataset();
}
Enter fullscreen mode Exit fullscreen mode

The controller-level attribute still performs useful baseline authentication. The broader read uses a domain guard because legitimate access may be represented through more than one role or capability. The higher-sensitivity operation uses a focused policy that can evolve independently.

This is not a universal template. It is a reminder to make each operation’s rule explicit.

Match the guard to subject, scope, and sensitivity

An authorisation review becomes easier when each action is classified along three dimensions.

1. Subject

Is the caller acting on their own resource, a resource related to them, or somebody else’s resource?

Self-service often needs object-level checks rather than a broad administrative permission.

2. Scope

Does the action return one authorised object, a page of objects, or an entire collection?

A caller who may read one related record does not automatically gain collection-wide visibility. Cardinality changes the boundary.

3. Sensitivity

Does the operation expose a routine projection, change state, or create a richer export?

Two actions over the same entity may deserve different policies when one combines more fields, covers a wider scope, or produces a portable artefact.

This classification is more reliable than deriving API rules from whichever controls happen to be visible on a page.

Test denial and preserved access

The obvious regression test asserts that an authenticated caller without the required entitlement receives a forbidden result. That is necessary, but incomplete.

The same change should also prove that each legitimate access path still succeeds. In a mature system, entitlement may come from a role, a capability claim, object ownership, or a domain relationship. A “secure” change that silently blocks valid work is still a regression.

For a mixed-purpose controller, useful tests include:

  • the sensitive action carries the intended policy;
  • an authenticated but unentitled caller is denied;
  • legitimate role-based access still works;
  • legitimate capability-based access still works;
  • baseline authentication has not been removed;
  • no action has accidentally acquired anonymous access.

Metadata and unit tests are fast and valuable, but they do not exercise the complete middleware pipeline. Add at least one integration test through a real test host for the highest-risk route. That verifies policy registration, authentication scheme selection, and middleware order together.

The trade-off

Action-level authorisation adds attributes, guards, tests, and review surface. It can also become inconsistent if developers must remember the rule for every new action.

A single controller-wide policy is easier to understand, but only when every action truly shares the same access semantics. Splitting self-service and privileged operations into separate controllers can make the boundary clearer, although it may fragment routing and add migration work.

The right choice is the one that makes the security model visible. For an existing mixed-purpose controller, explicit action rules plus a small classification test can be a pragmatic step. For new APIs, grouping operations by authorisation semantics is often cleaner.

A practical takeaway

During the next endpoint review, ignore the page for a moment. Inventory the API actions directly and label each one:

  1. self;
  2. single authorised resource;
  3. bulk collection;
  4. mutation;
  5. export.

Then ask what the server proves before executing each action. Finally, test one caller who must fail and every caller class that must still succeed.

Hide or disable controls to create a clear experience. Put the actual decision where the data or state change leaves the server.

Top comments (0)