A team can have good authorization checks and still expose a new endpoint by forgetting to attach them. This failure is especially easy to introduce when routes are registered one at a time: most protected handlers use authentication or authorization middleware, but one new handler is added without it and becomes reachable under the framework’s default behavior.

The consequence depends on what that route does. A missed guard can expose private data, allow a state-changing operation, or make an administrative function available to callers who should never reach it.

A useful defensive design is to make protected application areas private by default. New routes inherit an access requirement automatically, while genuinely public routes are explicit exceptions. This article explains why that default reduces security gaps, how to structure it without confusing authentication and authorization, and how to verify that the boundary stays intact as the application changes.

Put the safer outcome on the default path

Consider an application with these routes:

GET  /health
POST /login
GET  /account
POST /account/email
GET  /admin/reports

/health and /login are intentionally public. The other routes require an authenticated identity, and /admin/reports also requires a specific permission.

One design attaches protection separately:

/account          -> require login
/account/email    -> require login
/admin/reports    -> require login + require report access

This can work, but every new protected route depends on a developer remembering the security step. If a future /account/export handler is registered without the guard, the route may be public even though the developer intended it to be private.

The problem is not that the authorization rule was wrong. The rule was never reached.

A private-by-default structure reverses the burden:

public routes
  /health
  /login

protected routes
  require authenticated identity
  /account
  /account/email
  /account/export
  /admin/reports -> additionally require report access

Now a route placed in the protected area inherits the baseline guard. Making a route public requires a deliberate placement or exception.

The security principle is broader than any routing framework: make the common construction produce the restrictive state, and require an explicit decision to widen access.

Understand the threat model

This control reduces the risk of an authorization gap caused by omission: a developer creates a route that should be protected but forgets to attach the baseline access control.

It is useful because omissions are difficult to review reliably. A code reviewer can inspect a visible authorization check and judge whether it is correct. A missing check leaves less evidence to notice.

Private-by-default routing does not prove that an authenticated user is allowed to perform every protected action. It also does not repair a weak permission rule, an object-level authorization bug, a compromised account, or a route that was deliberately placed in the public area under a mistaken policy.

The trust boundary is therefore important:

request
   |
route classification
   |
public ---------> public handler
   |
protected
   v
baseline identity check
   |
additional action/object authorization when required
   v
handler

The baseline changes what happens when a developer adds a route. It does not eliminate the need for finer authorization decisions inside the protected side of the boundary.

Authentication is a baseline, not the whole policy

A common implementation mistake is to interpret “private by default” as “logged-in users may do anything.”

Suppose both of these routes are inside the authenticated area:

GET  /account/profile
POST /admin/disable-user

Requiring login is a reasonable baseline for both, but the second action needs a stronger decision. The application must still determine whether the current principal has permission to disable the target user.

A useful model is:

baseline gate: may an anonymous caller enter this application area?

specific authorization: may this principal perform this action
                        on this resource?

Private-by-default routing is strongest when it removes accidental anonymous exposure while leaving specific authorization close to the operation it protects.

Do not use route grouping as evidence that all authorization has already happened. A protected route can still contain records belonging to different users, tenant boundaries, role restrictions, or sensitive actions that require separate checks.

Make public access explicit and narrow

Most applications need some public routes. Login, account registration where offered, health endpoints, public documentation, and inbound integration endpoints are common examples, although their exact policies vary by product.

The goal is not to eliminate public access. It is to make it intentional.

Prefer a structure where a reviewer can answer two questions from the route definition or nearby policy:

  1. Why is this route reachable without the normal authenticated baseline?
  2. What protections does it still require?

A public webhook endpoint, for example, may not use an end-user session but can still require request authentication specific to the integration. A health endpoint may be public but expose only minimal status information. “Public” should mean that the normal user-authentication gate does not apply, not that the endpoint has no security requirements.

Avoid broad bypass mechanisms such as “skip authentication for anything under this prefix” unless the entire namespace is intentionally public and that invariant is tested. Wide exceptions make future routes inherit a weaker policy for the same reason that wide protected groups make them inherit a stronger one.

Keep the boundary easy to see

The exact implementation depends on the framework, but the design should make route classification difficult to miss.

One approach is to register public and protected routes in visibly separate groups. Another is to make authentication middleware global and maintain a small explicit allowlist of routes that do not require it. Some frameworks provide policy metadata that can express the same rule.

The important property is not the syntax. It is the default behavior when a developer adds a route without thinking about security metadata.

Ask this concrete question during design review:

If a developer adds a normal handler tomorrow and specifies no access-control option, is it reachable anonymously?

For an application area expected to contain private operations, the desirable answer is no.

Be careful with global middleware that runs after routing or can be bypassed by alternate dispatch paths. Static files, framework management endpoints, error handlers, secondary routers, RPC services, and separately mounted applications may have different middleware chains. Treat each externally reachable dispatch path as its own boundary until you have verified otherwise.

Test the default, not only named routes

Tests often prove that known sensitive endpoints reject unauthorized requests. That is useful, but it does not directly verify the construction rule that protects future endpoints.

Add tests around the routing policy itself. Depending on the application architecture, useful tests can verify that:

  • a representative route in the protected group rejects an unauthenticated request;
  • public routes remain reachable without a user session when that is intended;
  • privileged routes require their additional permission, not merely authentication;
  • route metadata or registration fails validation when a route has no explicit classification, if the framework allows such enforcement.

For systems where route enumeration is available, a test or build-time check can compare registered routes against an explicit public set and require every other route to carry the protected policy. This is particularly valuable when multiple modules register endpoints independently.

Do not make the test depend only on route names such as /admin. Names communicate intent to humans but do not enforce it. Test the policy the runtime actually evaluates.

Plan for failure behavior

The baseline guard also needs defined behavior when it cannot make a decision.

If authentication state cannot be validated because a required identity service or session store is unavailable, silently treating the request as anonymous and then allowing it through a public fallback can weaken the boundary. Protected routes should remain protected when their required identity decision cannot be completed.

At the same time, a private-by-default design should not accidentally make operational endpoints unusable during every identity-system outage. If a narrowly scoped health endpoint must remain reachable for infrastructure monitoring, classify it deliberately and constrain the information it exposes.

This is a trade-off between availability and access control, not a reason to make the entire application permissive during failures. Decide which routes genuinely need independent availability and design those exceptions explicitly.

Watch for common ways the default erodes

A private default loses value when exceptions become easier than following the protected path.

One warning sign is a growing public allowlist containing ordinary product endpoints. Another is duplicated routing code where some modules use the protected router and others use a raw framework router. A third is middleware ordering that lets a handler execute before the access-control decision.

Refactoring can also change the boundary unintentionally. Moving a route from a protected group to a top-level router may preserve its URL and tests for successful requests while dropping inherited middleware. Security tests should therefore include unauthorized cases, not only expected successful behavior.

Operational visibility helps here. Record enough information to investigate denied access and unexpected anonymous traffic without logging credentials or sensitive request contents. Monitoring is complementary: it can reveal mistakes or abuse, but it should not be the mechanism that decides whether a request is authorized.

Choose the simplest structure that preserves the invariant

A small service with three routes may not need a complex policy engine. A global authentication requirement plus two explicit public exceptions can be sufficient.

A larger application with multiple identity types, administrative functions, tenant boundaries, and integration endpoints may need several clearly defined route groups and finer authorization inside them. Defense in depth can include object-level checks, least-privilege roles, reauthentication for sensitive changes, audit logging, and tests that exercise cross-user boundaries.

The key invariant stays simple:

adding a route must not silently make it public

Choose an implementation that makes that property easy for developers to preserve and easy for reviewers and tests to verify.

Conclusion

Access-control bugs are not limited to incorrect permission logic. Sometimes the application never evaluates permission logic because a new route was registered outside the protected path.

Private-by-default routing changes the failure mode. Protected application areas inherit a baseline access requirement, and public access becomes an explicit exception. That reduces the chance that forgetting one middleware attachment turns a new handler into an anonymous endpoint.

Use the default for what it can guarantee: a consistent baseline boundary. Then apply specific authorization for the action and object being requested, keep public exceptions narrow, test the runtime policy, and verify that alternate routing paths cannot bypass the same decision.