The rise of easy-access, vaguely-capable LLMs have introduced a whole new wave of people to the joys of programming; the joy of having an idea and turning into something real and interactive right in front of your eyes even comes with a faster feedback loop now. Unfortunately, LLMs have also introduced nearly as many people to the seemingly neverending array of footguns available to programmers. It is becoming increasingly important, now more than ever, to be able to guide junior programmers towards safe and secure patterns. Because junior programmers today understand their code even less than junior programmers of a few years ago.
The concept of a guardrail is typically paired with a paved path. I don’t know where these ideas originated, but I’m pretty sure I heard Netflix talking about them a decade ago. A paved path is a standardized, pre-approved, well-documented route for shipping a system. Guardrails are then typically paired with the paved path so that it is difficult to accidentally deviate from the paved path. Note that the operative word there is “accidentally” - an engineer could intentionally choose to deviate from the path, and sometimes there are legitimate reasons to do so. Rather than restricting or denying everything, the goal is to guide the engineers so that the safest thing is also the easiest thing.
I spent a pretty good portion of my previous job building out paved paths and guardrails for our product. Systematically eliminating patterns that may have represented a latent danger - maybe not in the way they were implemented today, but that with a minor change could have been catastrophic. I spent several cross-country flights just working on bringing the code into flake8 compliance or working on static type checking with mypy. Neither of these were really within my job description as a security engineer. But I was playing the long game - setting standards today so that all the other engineers had to adhere to better patterns and code quality if they wanted to be able to merge tomorrow.
This is a bit of an aside, but if you’re a security engineer working in or supporting a python code base and you don’t have static typing setup in strict mode already, you really ought to do that. Static typing rapidly uncovers so many bugs in python applications that I think it should be the default going forward and only ignored on special occasions. I know many die-hard python fans will hate this take, because duck typing and the dynamic nature of the language are such a big appeal. Not to mention most founders who picked python for their product probably didn’t have type theory in mind when doing so, so you’re going to be fighting an uphill battle. But it’s worth it.
These sorts of changes - improving code quality through adopting best practice linting rules, adopting fixers that see a problem and automatically replace it with a better version, enforcing strict type checking with explicitly declared ignores, etc - are foundational for developing a secure application. Vulnerabilities are a subclass of bugs, and it stands to reason that if you build a foundation that roots out bugs, you will also root out vulnerabilities. Pun not intended.
I want to take the rest of this piece to write about how I like to construct guardrails for a repository so that future engineers have an easier time following the safe path. I’m going to be focusing on python because that’s the ecosystem I know best, but the principles can be applied to most any language, and some languages will get certain principles for free. (You don’t have to tell me you write rust, btw.)
These are not written in a particular order of importance and may be updated in the future as I find new patterns that work well for me.
I’m starting with one of the easiest ones that can be implemented on any repository where code matters. You should have branch protection rules in place for any branch that deploys code. At the very least, you should have a set of required jobs that must pass to merge the code, and you should require at least one peer review. This is a foundational guardrail that many future things will build upon. Without this, anyone can push code directly to prod without review, and without regard for whether it is even functional.
Required jobs tend to include basic linting, formatting, and a test suite at a minimum. More advanced required jobs may also include static type checking, migration linting, SAST code scanner checks, mccabe complexity linting, etc.
This is the “Shift left” of guardrails. Pre-commit hooks run before each commit, making sure that each commit meets certain criteria. Typically pre-commit hooks are used for ensuring that linters and fixers are running, all files end with a newline, your commit doesn’t have merge conflict markers in it, that sort of thing.
Some engineers really despite pre-commit hooks and it may take some time to win them over. It’s important to make sure your pre-commit hooks are optimized for runtime - if you suddenly make every git commit take a whole minute, you’re going to make enemies very fast. The best way to make sure things are optimized is to make sure that each pre-commit hook is only firing against the files that have changed in the commit. This helps keep most commits down to a very short amount of time running the hooks.
By the way, it’s your responsibility to fix the violations when you introduce a new hook, otherwise you’re just leaving problems for future engineers. An engineer at my previous job referred to the campsite principle - leave it better than you found it. The cool thing about introducing a new hook is that all of the errors tend to be the same, so even if you touch a hundred files, the changes are all pretty much the same and the cognitive load on the reviewer is minimal. The worst thing you can do is add new hooks, not run them against the whole codebase, and then some random engineer later touches a bit of code that violates the rule and now has to fix that problem in addition to what they were actually trying to work on.
Once you’ve got some pre-commit hooks in place, add a CI job that runs pre-commit against all the changes in the branch and mark it as required. It’s important to run against all changes from the base of the branch to the HEAD, otherwise someone can put a bunch of bad code into a middle commit in their pull request and then push a simple change to the README in the latest commit, and a naive pre-commit CI job will think this is good enough.
With the pre-commit hooks as a required job in CI, you now have a framework you can build on for programmatic analysis and linting, shifted all the way to when the engineer commits the code.
Linting is another fairly basic guardrail that should exist on any repository where code deploys. Linting itself is a broad term to programmatically analyze source code to find errors, style mistakes, or potentially bad patterns. Pretty much every programming ecosystem has a linting framework out there that you can adopt. In python it has historically been flake8 and it’s various plugins, though ruff has really taken the lead due to it’s speed and how extensive the built in plugin support is. Almost every flake8 rule set has already been ported to ruff, and ruff will scan your whole codebase much faster.
One thing you’ll quickly find on any large enough codebase is that there are times where your linter will fail because of something that you simply can’t fix. This is where it becomes important to add strategic ignore rules to your linters. The harsh reality is that sometimes the code really does just have to look like that. Typically you can add ignore rules directly in line with the code, which tends to be the most preferred option because it is less likely to randomly break when adding a new line when compared to tracking them in a separate file. Do not allow blanket ignore statements, though - you always want to be specific about what rule is being ignored.
Paired with linters are fixers, which take the notion of linting and applies programmatic corrections to the source code. Off the shelf fixers exist for upgrading to the latest python syntax, upgrading django patterns, fixing anti-pythonic patterns, and a fair bit more. If you use ruff, you can basically just pass --fix to the linter and it’ll also fix all the problems it can. Other fixers that I’ve gotten good use out of in the python ecosystem are pyupgrade and django-upgrade.
Your mileage will vary on which linters are available to you and which fixers are available to you. The important part is to get the framework in place. A clear method to add new rules, to see what is ignored and where, and to implement new fixers when you are ready.
The CODEOWNERS file in Github (and I’m sure other git forges have the same concept) is a crucial part of establishing an effective guardrail framework. This file allows you to define who the “owner” is for specific files and folders in a codebase. As a security engineer, you may want to use this to own any file that registers new HTTP routes to the application, for instance. But most importantly, you’ll want to use this to mark a security team as a code owner for the various guardrails you establish. By being an “owner” it ensures that you or your team are tagged to review changes that affect the files or folders you own.
In Django I usually register **/urls.py, settings.py, pyproject.toml, uv.lock, CODEOWNERS, and a few other patterns as security being a codeowner. This helps to ensure that when the attack surface of the application expands, I can get alerted to it as soon as possible.
Note: This feature inserts you or your team directly into the code review pipeline, and when paired with branch protection rules, may result in you blocking or delaying changes. It is critical that your team manages these reviews expeditiously, or you’re going to burn any good will you have with your engineering teams.
Tach allows you to define module dependency relationships between elements of your code base. This is a python-specific tool, and I honestly don’t know of any other languages that have a similar tool. But I think it is an extraordinarily useful idea, especially as codebases grow larger.
Tach has a config file, tach.toml, which defines all the rules for how your codebase can depend on itself. By itself this is not that useful, but it creates a framework for you to enforce architectural decisions that can pair well with your CODEOWNERS, as well as help to really clamp down on certain anti-patterns (like making it easier to reject potential cyclical dependencies). You can also use this to define which sections of the code base are allowed to depend on specific service clients, helping manage the use of external services.
Designing a framework by which all views in your application have explicitly defined authentication and authorization, and throttling controls can pair extremely well with other guardrails to effectively guarantee that any attempt to deploy a new unauthenticated or broad-authorization view gets a security review.
One way I like to do this is by creating AuthenticatedView and UnauthenticatedView base classes and then fixing all the views in the code base to inherit from one of these. It makes the concept of an unauthenticated view explicit, rather than “Oh I forgot to add the authentication handler.” But it also makes AuthenticatedView and UnauthenticatedView available in the inheritance tree, which enables programmatic assessment.
The overall trend here is that you want to create a few chokepoints for adding new views to the codebase, such that all views stem from a set of known base classes. When doing this, you can also set a sentinel value for things like permission_scheme or throttling class variables, requiring the subclasses to override the sentinel value or it’ll fail at subclass init.
If you’re using function based views, uh, sorry. Maybe don’t do that. But I’m sure there’s a way to achieve a similar amount of programmatic assessment - I just don’t know what it looks like. I’d love to hear from you if you have ideas on how to programmatically assert that all views are authenticated, authorized, and throttled appropriately when using function based views.
The service client pattern is a fantastic way to aggregate most of the outer edges of your system into a single place in your codebase. Anytime you need to make an outbound HTTP request to a service, it should belong to a service client. Create a base service client that implements basic functions like ensuring the right headers are set, the instrumentation is present, etc. Then all service clients inherit from the base service client.
If you successfully implement this, you can effectively ban the use of HTTP client libraries outside the service clients, making it much easier to analyze all of the potential sources and sinks for your data.
Say you need to log all outbound HTTP requests from your environment for compliance reasons. You can just add the logger to the base service client. Say you need to prevent ever sending outbound HTTP requests that don’t go over HTTPS. You can just add the restriction to the base service client.
Pairing with the previous pattern of making authentication, authorization, and throttling explicit, the next step is to create a set of tests in the existing test framework that programmatically visits every view that has a url registered to it and makes sure it meets the criteria. Making sure it inherits from one of our common base classes. Making sure it has authentication, authorization, and throttling explicitly defined for each new view.
But here’s where it gets a little bit fun: You will always have some unauthenticated views - e.g. the login view of every web app will always be unauthenticated. But you probably want to be involved in any future decisions to create new unauthenticated views. Maybe they are fine, but maybe they aren’t. So this is where you create a map of all the existing unauthenticated views in your tests - if a new unauthenticated view turns up that isn’t in your map of allowed unauthenticated views, fail the test.
Of course, if Claude is doing Claude things and the engineer isn’t paying attention, it will try to just add the new view to the allowed unauthenticated views map. To mitigate this, you’re going to create this test in its own test file and protect it with CODEOWNERS. Any attempt to add a new unauthenticated view will tag your security team for review.
This same pattern can be extended to allow broad-authorization views, allow broad-throttle views, etc. The point is that as long as you can introspect your application from your testing framework, you can write tests that enforce specific patterns. If you can’t introspect your application from your testing framework, you’re probably using a language where you can enforce this statically much more easily.
If you’re in a statically typed language, you probably take for granted all the benefits you get from it. Being forced to think about all the edge cases up front. Being forced to care about serialization and deserialization at the edges of your system. It must be nice. In Python, it’s the wild west.
You want to do string manipulation on that int? For sure buddy, it’ll be a runtime error, but you got it.
Static type checking helps to ensure that these sorts of trivial type errors are caught and handled. It will help to ensure that an if statement that looks like it is controlling execution flow is actually controlling execution flow and not just permanently true.
Python is full of little optimizations that, when caught from the wrong light, look a lot like footguns. Things like shortcircuiting, or understanding the difference between an Iterable and a Sequence and a List.
While detecting all of these trivial type errors, static type checking is also helping you make sure your authentication or authorization code is rock solid. It’s guaranteeing that your safety-critical methods are being passed what they expect and that they won’t behave unexpectedly at runtime.
This is a thing that I am annoyed I even have to say, but I’ve seen a lot of chatter online recently that would suggest that this isn’t a normal practice for some reason.
For those that don’t know, a lockfile is part of any good dependency management tool in any language, that captures the entire dependency chain. Not just the dependencies you declared in your file directly, but all the dependencies they have, and all the dependencies they have. The entire transitive dependency tree, captured with exact version numbers and hashes.
Any time you are working on your application or building it for deploying, use the lock file. Don’t install from package.json or pyproject.toml or Guido-forbid your hand-jammed requirements.txt. Use the lock file. Guarantee that the dependencies that all of your engineers are using are the ones that the application is tested with.
Not only will this give you a great deal of protection against rampant transitive dependency supply chain attacks like we’ve seen in the first half of 2026, but it will also give your builds a great deal more reproducibility. Literally life becomes so much easier if you just use your lockfiles. Your builds don’t randomly break on a Tuesday afternoon because some dependency changed out from underneath you. Your AWS keys don’t get sprayed across the internet because the sole developer of does-it-blend.js got pwned by a clickfix campaign and didn’t believe in 2FA.
Some people will say “don’t commit your lock file” but those people are wrong. I will note that if you are developing a library, your objective should be to use the loosest dependency requirements that are necessary for your code to run. But if you are developing an application, use your lock file, please.
This one is not much of a security guardrail in the way that most people think of it, but I guarantee it will save you from inevitable downtime. And since the CIA triad is still talked about, this one protects your A.
If you’re developing a web app, you probably have a database, and that database schema probably needs to change over time. Thus, the migration is born. The migration defines how to modify the database from the current schema state to the new one, and if you’re lucky, what to do to roll back the migration. Django has migrations built in, and for most other python projects there is alembic.
But migrations can be tricky. Adding an index to a table could be no big deal because the table is empty. Or it could be 10 minutes of downtime because it’s the user table and every request that every user makes to your web app depends on that table, so acquiring a lock takes forever. Marking a column not null is fine except when the column has a null value in it.
This is why it’s critical to lint your migrations. If you’re in django world, I’ve had good success with 3YOURMIND’s django-migration-linter.
Once you’ve got a lot of this stuff out of the way, it may be time to start building out your own linters and fixers. You’ll need to brush up on your knowledge of Abstract Syntax Trees, but the world is your oyster after that. You’ll have a framework in place where you can easily slot in new linters and new fixers, allowing you to systematically eliminate classes of bugs from your code base. Where possible, you’ll be able to automatically fix the problems for your engineers and they won’t know any different.
A few years ago I would have said that documenting your preferred patterns and guardrails was useless because no one would read the documentation anyways. But now Claude will read the documentation and steer engineers in the right direction based on the guardrails you’ve created. So document why each guardrail exists, what it is preventing, what to do instead. Claude will, by and large, follow your guardrails.
I don’t know if other languages have this but I’m sure they do to some degree - in python, flake8 and ruff have the capability to ban the use of unsafe modules, and provide a comment as to why it is banned. You can use this to ban things like os.system because your web app literally never needs to use os.system. You can use it to ban the use of SafeString in Django, because anyone creating a SafeString directly is almost certainly not creating a safe string. Define the modules to ban and provide an explanation why and/or an alternative to use. Engineers and Claude alike will be able to leverage this guardrail to avoid the unsafe thing.
If you implement all of these controls, it becomes difficult for an engineer to merge something that has security impact that doesn’t get surfaced to security for immediate review. The Branch Protection Rule ensures that the code doesn’t get merged directly and ensures that your required tests and pre-commit hooks run. Your linters and fixers make sure that basic bugs are caught immediately. You’ve written tests to dynamically test every url that gets registered for your application, allowing you to programmatically determine any time a new unauthenticated view is added. Maybe you’ve expanded that to even require security review any time a new authorization mechanism gets added. You’re in the loop on dependency updates without having to play dependabot whack-a-mole. CODEOWNERS is tagging you on every commit that is related to a security change. Claude is guiding your engineers towards the right path and away from the wrong one.
I think the most important takeaway from this is that we’ve chained together many small controls to result in a series of controls that works well together to prevent mistakes and security bugs. Security is not, and does not need to be, a series of large efforts done parallel to engineering efforts. It can be a series of small efforts that intersect with engineering efforts, and I’ve found this to be a very effective approach.
New posts in your inbox when they're published. No spam, unsubscribe any time.