What Authorization Is (and What It Is Not)
Authorization is the allow/deny decision made after a caller has been identified. The gateway’s job is to apply the same decision rules every time, for every request, based on trusted identity data (typically token claims) and the requested action (route + method). The goal is least privilege: callers only get the minimum permissions needed for their use case.
In practice, authorization at the gateway answers questions like: “Can this user call DELETE /users/{id}?” “Is this client allowed to write?” “Is the caller operating within their tenant?” These decisions should be deterministic, auditable, and consistent across services.
Defining Authorization Checks
RBAC: Role-Based Access Control
RBAC grants access based on roles such as admin, support, or viewer. Roles are usually coarse-grained and map well to administrative endpoints and operational tools.
- Good fit: admin consoles, internal tools, “break-glass” operations.
- Risk: roles become too powerful (“admin can do everything”), leading to over-privilege.
Scope-Based Access (OAuth2-style permissions)
Scopes are typically fine-grained permissions such as orders:read or orders:write. They map well to API actions and are often easier to reason about per endpoint.
- Good fit: public APIs, third-party integrations, separating read vs write.
- Risk: overbroad scopes (e.g.,
*orapi:full_access) undermine least privilege.
Least Privilege as a Design Rule
Least privilege means you design permissions around specific actions and data boundaries. Two practical patterns are: (1) separate read and write permissions, and (2) enforce data ownership boundaries (for example, tenant isolation) using claims.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Where Policies Live and How They Are Evaluated
Policies should live in the gateway configuration (or a centralized policy engine integrated with the gateway) so that enforcement is consistent and not re-implemented differently in each service. The gateway evaluates policies per route, typically in this order:
- Match the route: path + method selects a policy block.
- Extract trusted attributes: token claims (e.g.,
sub,roles,scope,tenant_id), plus request context (method, path, headers). - Evaluate allow/deny: rules decide whether to forward to the backend.
- Optionally transform: add derived headers for backends (e.g.,
X-User-Id) only after verification.
Keep the policy logic close to the route definition so it’s obvious what protects what. Avoid “global allow” rules that accidentally cover sensitive endpoints.
Example 1: Restricting Admin Endpoints (RBAC)
Goal: only users with role admin can access administrative routes.
Step-by-step
- Step 1: Identify admin endpoints (e.g.,
/admin/*,/internal/ops/*). - Step 2: Decide the required role(s), e.g.,
admin(and optionallysupportfor read-only admin views). - Step 3: Enforce at the gateway per route and deny by default.
- Step 4: Return a consistent error:
403 Forbiddenfor authenticated but unauthorized.
# Pseudocode gateway policy (route-level RBAC) routes: - path: /admin/* methods: [GET, POST, PUT, DELETE] policy: require_authenticated: true allow_if: any: - claim_contains: { claim: roles, value: admin } deny_status: 403Practical tip: if you have multiple admin roles, prefer explicit allow lists (e.g., admin, ops_admin) rather than pattern matching on role names.
Example 2: Read vs Write Scope Separation (Scopes)
Goal: allow read-only clients to fetch data but not modify it. This is one of the simplest and most effective least-privilege wins.
Step-by-step
- Step 1: Define scopes aligned to actions, e.g.,
orders:readandorders:write. - Step 2: Map HTTP methods to required scopes:
GETrequires read;POST/PUT/PATCH/DELETErequire write. - Step 3: Apply per route (or per method group) at the gateway.
# Pseudocode gateway policy (scopes per method) routes: - path: /orders methods: [GET] policy: require_authenticated: true require_scopes: [orders:read] deny_status: 403 - path: /orders methods: [POST] policy: require_authenticated: true require_scopes: [orders:write] deny_status: 403 - path: /orders/* methods: [PUT, PATCH, DELETE] policy: require_authenticated: true require_scopes: [orders:write] deny_status: 403Practical tip: avoid “combined” scopes like orders:read_write. They tend to spread and become the default, defeating the purpose of separation.
Example 3: Tenant-Based Constraints Using Claims
Goal: ensure callers can only access resources within their tenant. This is a data boundary rule, not just a feature permission. The gateway can enforce tenant constraints by comparing a trusted claim (e.g., tenant_id) to a tenant identifier in the request (path, query, or derived from the resource).
Common patterns
- Tenant in path:
/tenants/{tenantId}/invoices - Tenant in subdomain/host:
{tenant}.api.example.com - Tenant in query:
?tenantId=...(usually least preferred because it’s easy to omit or manipulate)
Step-by-step (tenant in path)
- Step 1: Standardize where tenant identity appears in requests (prefer path or host).
- Step 2: Ensure the token includes a trusted
tenant_idclaim (or a list of allowed tenants). - Step 3: At the gateway, compare
path.tenantIdtoclaims.tenant_id. - Step 4: Deny if mismatch, even if the caller has the right feature scope.
# Pseudocode gateway policy (tenant isolation) routes: - path: /tenants/{tenantId}/invoices/* methods: [GET, POST, PUT, DELETE] policy: require_authenticated: true require_scopes: [invoices:read] # for GET routes; use invoices:write for mutations allow_if: all: - equals: { left: path.tenantId, right: claim.tenant_id } deny_status: 403When a user can belong to multiple tenants, model the claim as a list (e.g., tenant_ids) and check membership rather than equality.
Policy Evaluation Per Route: Making It Predictable
Authorization becomes inconsistent when rules are scattered. A practical approach is to define a small set of reusable policy building blocks and attach them per route:
- require_authenticated: deny if no valid identity context.
- require_roles: allow if roles intersect with allowed roles.
- require_scopes: allow if required scopes are present.
- require_tenant_match: allow if tenant boundary is satisfied.
- deny_by_default: if no rule matches, deny.
Then, for each route, you compose the minimum set of checks needed. For example: an admin endpoint might be role-only; a customer endpoint might be scope + tenant match; a reporting endpoint might be read scope + additional constraints.
Pitfalls and How to Avoid Them
Trusting Client-Supplied Headers
A common mistake is to accept headers like X-User-Id, X-Role, or X-Tenant-Id from the client and treat them as authoritative. Clients can forge these values.
- Rule: only trust identity attributes derived from verified tokens or from gateway-side lookups.
- Gateway practice: strip inbound identity headers from external requests, then add your own headers after verification.
# Pseudocode: remove untrusted headers, then inject trusted ones policy: remove_request_headers: [X-User-Id, X-Roles, X-Tenant-Id] set_request_headers_from_claims: X-User-Id: claim.sub X-Tenant-Id: claim.tenant_idOverbroad Scopes
Scopes like api:*, full_access, or “one scope to rule them all” make it hard to reason about access and easy to leak privileges.
- Fix: define scopes around resources and actions (
resource:read,resource:write). - Fix: keep privileged scopes rare and tightly controlled; avoid using them for normal clients.
Inconsistent Policies Between Services
If some services enforce authorization internally while others rely on the gateway, you can end up with gaps: a route might be protected at the gateway but exposed through another path, or a backend might assume the gateway checked something that it didn’t.
- Fix: decide a clear contract: what the gateway guarantees (e.g., authentication, scopes, tenant match) and what each service must still validate (e.g., object-level ownership, business rules).
- Fix: standardize policy names and required claims across routes and services.
- Fix: keep a single source of truth for route-to-policy mapping and review it like code.
Confusing 401 vs 403
- 401 Unauthorized: caller is not authenticated (missing/invalid identity).
- 403 Forbidden: caller is authenticated but lacks permission.
Consistent status codes help clients handle errors correctly and help you monitor authorization failures.
Policy Checklist for Every Exposed Endpoint
- Identity: Does this endpoint require an authenticated caller? If yes, is it enforced at the gateway?
- Permission model: Is access controlled by roles, scopes, or both? Are required roles/scopes explicitly listed?
- Least privilege: Are read and write operations separated (different scopes/roles)?
- Tenant boundary: If multi-tenant, how is tenant determined (path/host)? Is it compared to a trusted claim?
- Default behavior: If no rule matches, is the request denied by default?
- Header trust: Are client-supplied identity/tenant headers stripped? Are trusted headers injected only after verification?
- Consistency: Is the same policy applied across all routes that reach the same backend capability?
- Error handling: Are
401and403used consistently? Is sensitive detail avoided in error bodies? - Auditability: Can you log which policy allowed/denied (without logging secrets) for troubleshooting and reviews?