Why static scope and audience checks aren't enough, and how policy-as-code lets you decide who gets a token — and how it behaves — at the moment it's issued.
Scopes and audiences answer two questions well: which API is this token for, and what can it do there. They've been the backbone of OAuth authorization for a decade, and for good reason. But they answer those questions the same way every time. A client either has read:billing or it doesn't. The audience either matches or it doesn't. There's no room for "but only from the office network," "but not at 3 a.m.," "but only for admins who signed in recently," or "and make this particular token short-lived and revocable."
Real access requirements are rarely that flat. This post looks at why static access control runs out of road, what context-aware authorization at token issuance looks like instead, and how expressing those rules as policy-as-code keeps them in one place instead of scattered across your stack.
Audience and scope checks are necessary, but they're static. They describe a fixed relationship between a client and an API that doesn't account for the context of a request.
Consider a billing API. What you actually want is something like: first-party clients can read billing data, but writing requires an admin who authenticated recently; partner integrations are allowed only from approved networks; and anything touching refunds is blocked outside business hours. None of that fits in a scope list.
So the rules leak into other layers. The API gateway enforces IP ranges. A piece of middleware checks the time. A request handler inspects the user's groups. Each works in isolation, but together they become impossible to reason about:
There's also a subtler problem. All of these checks run after a token has already been issued. The token exists; you're only deciding whether to honor it on a given request. Anything that gets hold of that token — a leaked log, a compromised dependency — is holding a credential that was minted without ever consulting the context that should have governed it.
The better place to make these decisions is before the token is ever created.
At the moment of issuance, the authorization server knows a great deal about the request: which client is asking, which user (if any) they're acting for and which groups that user belongs to, the grant type being used, the scopes and audiences requested, the source IP and its resolved country and network zones, the time of day, whether the caller is a browser or a server, and whether a client certificate was presented.
That context is exactly what flat scope checks throw away. If you evaluate it before minting the token, the question shifts from "is this token valid?" to "should this token exist at all, given everything we know right now?" A request from an unapproved network, or for a write scope without a recent admin login, simply never produces a token. There's nothing to leak, replay, or revoke later, because nothing was issued.
This doesn't replace audience and scope validation at your APIs — you should keep doing that. It adds a gate earlier in the chain, where the most context is available and the cost of a "no" is lowest.
Context-aware decisions are only an improvement if the rules themselves are easy to read, change, and trust. Burying them in imperative code recreates the original problem in a new location. This is where a dedicated policy language earns its place.
Cedar is an open-source policy language originally built by AWS for fine-grained authorization. It's declarative, readable, and — importantly — analyzable: policies can be validated against a schema before they're ever used, so a typo in an attribute name is caught up front rather than in production.
A Cedar policy is a single statement that either permits or forbids a request, evaluated against a principal (the client), an action (issuing a token), a resource (the API), and the request context. Three rules keep the model predictable:
| Rule | What it means |
|---|---|
| Default deny | If nothing permits a request, it's denied. Access is always granted explicitly. |
| Forbid wins | If any policy forbids a request, it's denied — even when another policy permits it. |
| Order-independent | Policies are declarative; the outcome doesn't depend on how they're arranged. |
Those properties matter in practice. Default deny means a forgotten rule fails safe. Forbid wins makes guardrails reliable — a "never from blocked networks" rule can't be accidentally overridden by a broad allow elsewhere. Order independence means you can add and remove rules without worrying about sequencing.
And a policy reads close to plain intent. Restricting an API to a specific network zone is a single condition:
permit (principal, action, resource == Api::"https://api.example.com/billing")
when { context.network_zones.contains(NetworkZone::"office") };
The same shape extends to user groups, IP ranges, countries, time windows, grant types, and client certificates, composed however the requirement demands. Because the rules live together and read declaratively, "who can call this API and when" becomes something you can answer by looking — not by tracing code across systems.
Pushing the decision to issuance time unlocks something a downstream check can't do: shaping the token itself. Once a policy has decided a token should exist, it can also decide what kind of token that is — overriding the API's defaults for that specific request.
This means one API can issue different tokens for different situations without any change to the API itself. Browser-originated requests might receive short-lived, session-bound reference tokens, while trusted server-to-server clients get standard JWTs — decided entirely at issuance. It keeps the blast radius of any single token proportional to the risk of the context that created it.
Pulling access decisions into policy-as-code at issuance time pays off in a few concrete ways:
The trade-off is a new concept to learn — a policy language and an evaluation model. But it's a concept that consolidates logic you already have spread across your stack, rather than adding a wholly new layer.
This is exactly the model behind MonoCloud's API Access Policies. Every access token MonoCloud issues passes through Cedar policy evaluation first — at the authorization endpoint, at the token endpoint across all grant types, and again on every refresh. If no policy permits the request, no token is issued.
You don't have to write Cedar by hand to use it. For common cases — allow this client, restrict to these scopes, permit or deny — basic policies are configured with structured fields in the dashboard and compiled to Cedar for you. When you need full context-aware rules, advanced policies let you write Cedar directly, with a library of sample policies and an AI assistant that drafts a policy from a plain-English description like "Allow billing-admin during business hours from corporate IPs." Either way, every policy is validated against the schema before it can be saved, so invalid rules never reach evaluation. Policies can also carry the token-shaping overrides described above, applied when they permit a request.
If you'd rather spend your time building your product than wiring authorization logic across your stack, explore the MonoCloud Docs or sign up for free and add your first policy in a few minutes.