Skip to main content

In-Process Authorization with Guards: The Default That's Enough Until It Isn't

· 6 min read
Vladyslava Prykhodko
Engineering Technical Lead & Architect

Once you have more than a handful of services, "can this caller do this thing to this resource?" stops being a one-liner. The answer usually depends on attributes — who the subject is, what they own, which tenant they belong to, what action they're attempting, sometimes the time of day. That's attribute-based access control, ABAC: the decision is a function of subject, resource, action, and context, rather than a flat list of roles.

The interesting question isn't whether to do ABAC. It's where the decision gets computed. There's a whole spectrum. This article is the cheap, simple end of it — and most teams should start, and often stay, here. Part 2 measures what the externalized alternative actually costs once you genuinely need it.

Everything below is illustrative and generalized — a reference model, not data or code from any specific system. Substitute your own.

What a guard actually is​

A guard is an in-process check that runs on the request path before your handler does. In NestJS it's a CanActivate; in other frameworks it's middleware, an interceptor, a decorator. The mechanics differ, the idea doesn't: intercept the request, decide allow or deny, let it through or reject it.

The useful way to see it is to split the one job into two, even though a guard does both in the same place:

// The decision — a pure ABAC rule. No framework, no I/O.
function canAccess(subject: Subject, resource: Resource, action: string): boolean {
if (subject.role === 'admin') return true;
return (
resource.ownerId === subject.id ||
(action === 'read' && resource.departmentId === subject.departmentId)
);
}

// The enforcement — a NestJS guard that gathers attributes and applies the verdict.
@Injectable()
export class AbacGuard implements CanActivate {
constructor(private readonly attrs: AttributeStore) {}

async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest();
const subject = await this.attrs.loadSubject(req.user.id);
const resource = await this.attrs.loadResource(req.params.id);
return canAccess(subject, resource, req.method);
}
}

The attributes live in a regular database, editable from an admin UI. Change someone's department or an object's owner and the next decision reflects it without a deploy. That's the data side of ABAC — and it's identical regardless of where the decision runs.

It's worth borrowing one piece of vocabulary from the XACML model here, because the whole series hinges on it. There's a PEP (Policy Enforcement Point) that intercepts and applies the verdict, and a PDP (Policy Decision Point) that computes it. In the snippet above, the guard is the PEP and canAccess is the PDP. The defining trait of the guard approach is that the PEP and the PDP are the same process — the decision is computed right where the request is being served.

In-process authorization — PEP and PDP in the same process


Why this is the right default​

In-process authorization has properties that are easy to undervalue until you've paid to give them up:

  • The decision sits next to the data. Subject and resource attributes are a query away, often already loaded. No round trip to a separate service to ask permission.
  • No extra moving parts. No sidecar, no policy server, no sync agent, nothing new to deploy, monitor, or page someone about at 3 a.m.
  • Each module owns its policy. Domain-specific authorization lives with the domain it protects. There's nothing to centralize, and centralizing it would only add coordination for no gain.
  • It's trivial to reason about and test. canAccess is a pure function. Unit-test it like any other piece of logic — no test harness for a policy engine required.

And the part that surprises people when they later look at the alternatives: the incremental infrastructure cost is essentially zero. The decision runs on CPU you're already paying for — the application pod. There's no second container to reserve capacity for, no network hop on every call. The cost of guards is in code and maintenance, not in compute.

Hold that thought. Part 2 is entirely about what happens to that line item when the PDP moves out of the process.

For a single-framework codebase with domain-local rules, this is not a compromise you'll grow out of quickly. It's the correct engineering choice. Don't reach for a policy engine because it sounds more serious.


When guards stop being enough​

Guards break down not because they're wrong, but because their defining trait — PDP and PEP fused in-process — becomes a liability under specific conditions. Consider externalizing the PDP when at least one of these is true:

  • A polyglot estate. Guards in NestJS are TypeScript. The moment a Go or Python service appears, you reimplement the same authorization logic per language, and now you have N implementations to keep consistent. An external PDP gives you one policy language across all of them. This is the most concrete trigger.
  • Cross-team policy governance. When a security team needs to own policy centrally — rather than trust that every team independently reimplements tenant isolation, deny-by-default, and break-glass the same way — in-process logic scattered across services is exactly what you don't want.
  • Compliance and audit. If you need a single, tamper-resistant decision log of who-accessed-what across the whole fleet, hundreds of guards each logging in their own style won't give it to you.
  • Changing policy without a redeploy. Externalized policy can be revoked org-wide in minutes. In-process policy is code: a change means shipping it everywhere.
  • A no-drift guarantee. A service that doesn't contain the decision logic can't quietly start deciding differently after the next commit. With guards, every service technically can.

Notice that none of these is "we have a lot of services" on its own. Scale alone doesn't break guards — a hundred services with one language, autonomous teams, and no central-audit requirement are perfectly well served by in-process checks. What breaks guards is heterogeneity and governance: many languages, one security owner, a regulator asking for one log.


What's next​

If you've hit one of those triggers, the textbook move is to pull the PDP out into its own component — typically OPA, with OPAL keeping its policies and data fresh, deployed as sidecars next to every service. It's a clean architecture and it delivers exactly the properties above.

It also has a price tag that's larger and stranger than most teams expect. Part 2 measures it: where the money actually goes, why the biggest waste lands on your quietest services, and what one architectural change does to both the bill and the resilience of the authorization layer.