Yago Marinho
Back to the articles
  • Auth
  • Architecture
  • Python
  • Security

Implementing authorization in a legacy system: challenges from the trenches

I faced several databases, an API I couldn't touch and the risks of a generic engine — and learned to deliver well within limits I didn't choose.

Photo of Yago MarinhoBy: Yago MarinhoLast updated on:
💡
This is the third and last article in the series about an authorization engine I created under a private contract. The first one covers the problem and the idea, and the second, what the engine delivered; this one covers the challenges. The original code and the client's details stay out, and the examples were written from scratch, in a made-up domain.
In the OWASP Top 10, the list of the most critical flaws in web applications, broken access control has held first place since the 2021 edition — and it's still there in 2025. In other words: of all the ways an application can go wrong, the most common one is someone being able to do what they shouldn't.
That fact says a lot about authorization, but it doesn't say everything. Besides being hard by nature, authorization is rarely built from scratch, in a clean project. Most of the time, it's born inside a system that already exists, with decisions made before we arrived and with limits that weren't ours to choose.
That was exactly my case. In the two previous articles, I told how I created an authorization engine for an application without being allowed to touch the central permissions API, and what it delivered. Now I want to talk about the part that usually stays out of articles: the challenges — the ones I faced during the delivery and the ones I learned to see when I looked at the engine more calmly.

The scenario: delivering within limits

Before the challenges, it's worth recalling the scenario. I worked as a developer on one of the applications of an ecosystem with several systems, data spread across several databases and a central permissions API that every application called. The architecture of that ecosystem wasn't my decision, and the task I received came with a clear condition: solve the application's authorization without touching the central API.
This isn't a criticism of whoever made those decisions. Each decision in that ecosystem was made at a given moment, with the constraints of that moment — and that's how it is in practically every system with a few years behind it. For me, the point is a different one: how do you deliver something good when a large part of the ground is already built?
Let's get to the challenges.

Challenges and solutions

1. Two sources of truth

Challenge: by moving the grants into the application's database, it started having its own users and grants, while the other applications kept using the central API. In practice, there were now two sources of truth about who can do what.
Solution: to make it explicit instead of hiding it. The permission model applied only to the scope of that application: it owned its grants, and the central API kept owning the grants of the others. There was no synchronization between the two, and that was a conscious choice.
Obviously, that choice has a price. Whoever manages access now manages people in two places, and a revocation made in the central API doesn't reach the application on its own. If the scope grew, the path I'd propose is the one I mentioned in the first article: the central API as the place where grants are registered, and each service with a local copy of the grants it cares about, updated by events. Management stays in one place; the decision stays close to whoever needs it.

2. Where the rule lives

Challenge: an authorization rule has to live somewhere, and each option has its cost:
  • In the code: the rule is reviewed, versioned and tested along with the application, but any change requires a deploy.
  • In a configuration file, like a JSON: it changes without touching the code, but it still needs a deploy and loses the compiler's checks.
  • In the database, per route: it changes without a deploy and becomes a single source, but it starts requiring strong validation, because a wrong rule in the database is a security flaw in production.
Solution: at delivery, the requirement was declared in the code of each route, and the possible permissions were registered in the database. That brought a new concern: a route can require a permission nobody registered. And if that error only shows up when someone hits the route, it shows up too late. That's why I recorded as a next step validating the rules and the engine's schema when the application starts: if something is wrong, the application doesn't even start.

3. The cost of each request and the cache

Challenge: bringing the data close removed the trip to the central API, but not the trip to the database. On every request, the application still had to read the person's grants. And grants are exactly the kind of data that almost never changes: someone gains a role, loses another, and that's it.
So the question comes up: “Why fetch from the database, on every request, something that almost never changes?” The answer is simple: there's no need to — as long as revocation still takes effect immediately.
Solution: caching the grants was recorded as the next step of the delivery, with a rule I consider non-negotiable: the cache expiry is only a safety net; what invalidates the cache is the revocation. Take a look below at how that works:
CACHE_TTL = 300 # seconds: only a safety net def user_grants(user_id, cache, database): grants = cache.get(user_id) if grants is None: grants = database.grants_of(user_id) cache.set(user_id, grants, CACHE_TTL) return grants def revoke(grant_id, cache, database): user_id = database.revoke(grant_id) # revoking clears the person's cache right away, without waiting for the TTL cache.delete(user_id)
The main upsides of this design:
  • Cheap reads → the second lookup for the same person already comes from the cache.
  • Immediate revocation → whoever loses access loses it right away, not five minutes from now.
  • Expiry as a safety net → if an invalidation fails, the damage lasts at most as long as the cache expiry.

The risks of a generic engine

A generic engine solves a lot, but it brings risks that a hand-written rule, in an if, doesn't have. Since the engine compares any field with any comparator, a comparison detail becomes a hole in every route at once.
These are the risks I learned to look for in any engine of this kind. For anyone about to build one, I believe they're worth more than any new feature.

#Case 1: the missing field that authorizes

In the first article, I left a comment in the engine: a missing field never authorizes. I promised to come back to it, and here's why. Notice the difference between two versions of an equality comparator:
def naive_equal(required, granted): return required == granted def equal(required, granted): return required is not None and required == granted # no organization in the request, and none in the grant either naive_equal(None, None) # True: it authorized without knowing over what equal(None, None) # False
What's happening here? All it takes is a parameter that didn't arrive in the request and a grant that doesn't have that field for the naive version to conclude that nothing equals nothing — and authorize. In an isolation field, like the organization, that's exactly the flaw OWASP puts in first place. The rule I follow is simple: absence never authorizes.

#Case 2: names that become the same

It's tempting to normalize names at decision time: strip spaces, ignore capitalization, swap hyphens for underscores. The problem is that normalizing also means merging different names:
def normalize(name): return name.strip().lower().replace("-", "_").replace(" ", "_") normalize("monthly-report") == normalize("monthly_report") # True
In authorization, that means a grant for one resource can end up applying to another. The fix I consider correct is to flip the moment: the name is checked when it's saved and compared exactly at decision time. Take a look at the snippet below:
import re VALID_NAME = re.compile(r"[a-z][a-z0-9_]*") def validate_name(name): # the name is checked when it's saved, not tidied up at decision time if not VALID_NAME.fullmatch(name): raise ValueError(f"name doesn't follow the pattern: {name!r}") return name
The same goes for “friendly” aliases, like treating all as a wildcard. The wildcard is *, and only *. An alias hidden in normalization is a wildcard nobody sees.

#Case 3: “any” instead of “all”

When a request involves several targets — a bulk action on documents from two organizations, for example —, the right question is whether the grants cover all of them. A “contains” comparator usually answers something else:
def any_of(required, granted): return any(target in granted for target in required) def all_of(required, granted): return bool(required) and all(target in granted for target in required) # the request touches two organizations; the grant only covers one request = ["org-a", "org-b"] granted = ["org-a"] any_of(request, granted) # True: the request would get through halfway all_of(request, granted) # False
Notice the bool(required) too: a request with no target at all can't be considered covered. It's the same principle as the missing field, showing up in another form.

#Case 4: the configuration error that only shows up on a request

In a schema-driven engine, a comparator's name is just text. If it's misspelled, the error only shows up when a request goes through that field — and it shows up as an internal error, not as a denial. The prevention is the same as in the second challenge: validate the whole schema at startup, rejecting any unknown comparator before the first request.

#Case 5: the test that can't tell “denied” from “broke”

Last, a word of care about tests. If a denial test only checks that the response was a 500 error, it passes both when the engine denies and when the code breaks halfway through. A denial needs its own status code, usually 403 (Forbidden), and the test needs to check exactly that code. Otherwise, the suite stays green precisely on the day something goes wrong.

Influencing without deciding

There's a challenge that doesn't show up in the code, but that ran through this whole delivery: how do you propose improvements when the decision isn't yours?
I was one of the developers on the application, not the person who defines the ecosystem's architecture. The constraint of not touching the central API came ready-made, and it had its reasons: that API served every other application. What I could decide was how to solve the problem within my scope. And that's where the engine was born.
Some lessons from that position:
  • Read the constraint as a requirement: instead of fighting the limit, use it to design the solution. It was the constraint that led me to separate the data from the decision.
  • Deliver what's yours, and deliver it well: a good solution within your scope is more convincing than a big proposal outside it.
  • Leave the next step where the next person will find it: what was left for later — the cache and the startup validation — was recorded in the code itself, right next to where it would need to go.
  • Show the cost before proposing the change: an architecture rarely changes because of an opinion, but it often changes because of a well-explained risk.

Roadmap and next steps

To close the series, here's what I'd take into the next version of an engine like this one:
  • Absence never authorizes, with isolation fields always required.
  • Names checked when saved and compared exactly at decision time, with no wildcard aliases.
  • Schema and rules validated at startup, before the first request.
  • A coverage comparator for requests with several targets.
  • A log of every decision, with the field that denied it, to investigate a denial without guessing.
  • Property-based tests, which automatically generate thousands of cases to check rules that can never break, such as “a grant from one organization never reaches another”.

Conclusion

I started this article with OWASP saying that broken access control is the most common flaw on the web. After building this engine, I believe I understand why a little better: authorization is made of details, and each detail — a field that didn't arrive, a normalized name, an “any” instead of “all” — can open a door in every route at once.
But the most important lesson of this series, for me, isn't technical. None of the three articles is about an ideal project, built from scratch, with total freedom. They're about a task with a clear constraint, in a system that already existed, and about how it's possible to deliver something good — and honest about its own limits — without deciding the architecture around it.
As I wrote in the first article of this blog, the ability to share knowledge is as important as, or even more important than, simply having it. And telling the challenges, not just the wins, is sharing knowledge too.
If you've also had to deliver within limits you didn't choose, I invite you to share your experience. After all, that's how a community learns together.
Photo of Yago Marinho
Yago MarinhoSenior Software Engineer. I build AI-powered products, from design to deploy.
See portfolio ↗