TL;DR
- A Lambda authorizer's return type is an IAM policy document. If your permissions are already expressible as policy statements, you can store them as IAM policies and skip building an entitlements service entirely.
- You inherit a well-specified evaluation engine, a grammar that already matches your resources, existing tooling, and audit for free. For a team that won't staff a permissions service, this is the difference between a real model and a list of admin emails.
- The cost is one sentence long: you've put a control-plane API on the request path. Those have lower throttle limits than data-plane services, are eventually consistent, and aren't built for that traffic profile.
- So you cache — and the cache TTL silently becomes your permission revocation window. That's a security property, not a performance setting. Give it an invalidation hook.
- The pattern breaks when permissions stop being expressible as static policy — per-record ownership, time-bounded grants, anything depending on request content. Notice that boundary before you're across it.
- In authorisation code specifically, a misleading function name is a defect even when the behaviour is correct.
There's a detail in API Gateway's Lambda authorizer contract that most people treat as ceremony.
You don't return true. You don't return a user object. You return an IAM policy document — statements with an action, an effect, and a resource ARN — and the gateway evaluates it against the request that triggered you.
The overwhelmingly common implementation: verify the token, look up permissions somewhere, and if the answer is yes, hand back a wide-open Allow on execute-api:Invoke for *. The policy is a return type you tolerate.
But the return type is a policy language. Which raises a question worth taking seriously: if the answer has to be a policy document anyway, why not store the permissions as policy documents?
The pattern
The caller presents a token. Among its claims is a list of roles their identity group maps to — real IAM roles in your account.
export const handler = async (event) => {
const claims = await verifyToken(event.authorizationToken);
const roleNames = claims['cognito:roles'].map(arn => arn.split('/').pop());
const policies = await Promise.all(roleNames.map(fetchRolePolicies));
const allowed = policies
.flat()
.flatMap(p => Array.isArray(p.Statement) ? p.Statement : [p.Statement])
.filter(s => s.Effect === 'Allow');
if (!allowed.length) return deny(event.methodArn);
return {
principalId: claims.sub,
policyDocument: {
Version: '2012-10-17',
Statement: allowed.map(s => ({
Action: 'execute-api:Invoke',
Effect: 'Allow',
Resource: s.Resource
}))
}
};
};No permissions table. No entitlements service. No mapping layer between "what the business calls a permission" and "what the gateway understands" — because they're the same artefact.
Why I think this is underrated
The reflex reaction to "we used IAM as our RBAC store" is a wince. I'd push back on that.
You already have a policy engine, and it's better than the one you'd write. Explicit deny beating allow, wildcard matching on hierarchical ARNs, precise evaluation ordering. Most hand-rolled permission systems reinvent about 40% of this, and discover the missing 60% one incident at a time.
The vocabulary already fits. API routes are already ARNs. arn:aws:execute-api:eu-west-1:acct:api-id/prod/GET/orders/* is a resource identifier whose structure maps cleanly onto how you'd naturally want to express "can read orders in production". No translation layer needed, because there's nothing to translate between.
Permission changes are audited for nothing. Every role and policy change is a CloudTrail event with an identity attached. Building that into a bespoke service is real work teams routinely defer past the point where they need it.
There's no new datastore. No schema, no migrations, no backups, no availability story, no consistency story, no on-call rotation.
The operational story is one your infrastructure people already know. Granting a new consumer access is attaching a policy — an existing task with existing tooling and existing review.
As a decision record:
Decision. Model API authorisation as IAM policies attached to roles referenced by identity-provider group claims. The authorizer expands them at request time and reflects them to the gateway.
Context. Many APIs, heterogeneous consumers, no appetite to build and staff an entitlements service, and a gateway whose authorizer contract is already a policy document.
Consequence. No new datastore. A well-specified grammar with an existing evaluator, existing tooling, free audit. The gateway does resource matching, so the authorizer stays small.
Trade-off. A control-plane API is now on the request path of every authenticated call.
That last line is the whole cost, and it's substantial enough to deserve the rest of this article.
The cost, stated properly
Cloud control-plane APIs — the ones that manage resources rather than serve traffic — are built for a completely different traffic profile than your API.
They have lower throttling limits than data-plane services, and those limits are account-wide. So your API's availability now depends on how much other automation in the same account is hitting the same control plane. A Terraform run, a compliance scanner, an over-eager tagging Lambda — any of them can contribute to throttling your authorizer. That coupling is invisible until the day it isn't, and it's genuinely hard to diagnose because the symptom appears in a completely unrelated system.
They're eventually consistent. A permission granted moments ago may not be visible yet.
Their latency is not something you'd choose to put in front of every request. Several sequential calls to list and fetch policy documents, on a cold path, is real time added to every authenticated call.
And there's a quieter cost: provider quotas become product limits. Policies per role, statement size limits, tags per identity. These were operational details. Now they constrain how many permissions a customer can hold, and you'll find the limits by hitting them in production.
The cache is where this gets interesting
The obvious fix is caching, and this is where I've seen good designs go wrong.
API Gateway will cache an authorizer's result for you, keyed on the identity source, up to an hour (ResultTtlInSeconds, or ReauthorizeEvery in SAM). It's per-authorizer-per-API, though — with many APIs you pay the expansion cost repeatedly for the same caller. So the tempting move is to disable gateway caching and cache the decision yourself in a shared store, with a longer TTL, hit once across all APIs.
That works. But look carefully at what the TTL now is.
It is not a performance parameter. It is your permission revocation window.
Remove a policy from a role and every caller with a cached decision keeps their old permissions until the entry expires. At one hour, that's an inconvenience you can explain. At seven days, it's a security property — and it's expressed nowhere except as an integer next to a cache write.
If you're going to do this, three questions need answers before you pick the number:
What revocation latency is actually acceptable? That's a question for whoever owns your compliance posture, not for whoever is writing the authorizer.
Is there an invalidation path? A small function that clears a principal's cache entry, invoked whenever their permissions change, turns a seven-day window into a seven-day window with an escape hatch. It costs almost nothing to build and nobody builds it after the fact. Build it with the cache.
Is the cached decision complete? If you cache per-principal, the cached policy must contain that principal's full entitlement — not the subset relevant to the request that populated it. Cache a partial answer and you'll either over-permit or start denying valid requests depending on which endpoint they hit first. This is subtle and it's the bug I'd expect to see.
Write the TTL decision down as a security control, in the same place you document your other ones. A number chosen for performance reasons that silently governs revocation is exactly the kind of thing that surfaces during an audit as a surprise.
Where the pattern stops working
Be honest with yourself about the boundary, because crossing it accidentally is expensive.
This works while your permission model is expressible as static policy statements about resources. The moment you need:
- "this consumer can read their own records but not others'"
- a grant that expires at a specific time
- a permission conditional on request content
- a permission derived from a relationship in your data
you're outside what a policy document attached to a role can say. IAM condition keys cover some of it, but you're now writing conditions in a language your application can't easily read back — and the pragmatic escape hatch is to add a check in the handler. At which point you have two permission systems, and the interesting failures live in the gap between them.
That's not an argument against the pattern. It's an argument for noticing the boundary and deciding deliberately when you reach it, rather than accreting handler-level checks until nobody can answer "what can this caller actually do?"
One craft note, because it matters more here than elsewhere
A small thing worth saying, because authorisation code gets read by people making safety judgements.
If you write a helper that takes the requested resource ARN as a parameter and doesn't use it — because the gateway does resource matching downstream when it evaluates your returned policy — the behaviour is fine. The gateway genuinely handles it. But the code now asserts something it doesn't do. A reviewer sees the requested ARN being passed into something called isAllowed(statement, methodArn) and reasonably concludes the filtering happens there.
It doesn't. The safety comes from somewhere else entirely, and nothing in the file says so. The next person's change will be built on their reading of that name.
In authorisation code, a misleading name is a defect even when the behaviour is correct. If the gateway is doing your matching, put a comment where a reader would otherwise assume you were.
The takeaway
Check whether your permission model is already expressible in a language you already have. Not just IAM — the same reasoning applies to database row-level security and service-mesh policy. If it maps cleanly, use the existing engine. You inherit an evaluator, a grammar, tooling and an audit trail, and you skip a system you'd have to run forever.
Then check the traffic profile of the store you just chose. If the answer is "we'll cache it", the cache is now part of your security model — and its TTL is a policy decision that needs an owner and an invalidation path.