Danielle Hoopes7 pieces10 min
How I Made Claude Code Enforce OWASP Rules (So I Don't Have To)
Every developer knows the rules. Validate input. Use parameterized queries. Never log passwords. Check CSRF tokens. Don't bind directly to your domain model.
But here's the thing: knowing the rules and following them under pressure are two different activities. You're deep in a feature sprint, your PR is already massive, and you just need this one controller action to work. You'll come back and add the anti-forgery token later. You'll refactor that raw SQL query next week. You'll add the aria-label before it ships.
You won't. I know because I've been on both sides of that code review.
So I did something about it. I built a Claude Code skill that enforces OWASP Top 10 rules on every piece of code Claude writes for me. Not when I ask. Not when I remember. Every time. Automatically.
Here's how, and why your team should steal it.
1 min read
The Problem With Security Knowledge
Security knowledge lives in three places at most companies:
-
The docs nobody reads. Confluence pages written two years ago by someone who left. Maybe an internal wiki with "Security Best Practices" that was last updated before your framework got a major version bump.
-
The heads of senior devs. They catch it in code review. Sometimes. When they have time. When the PR isn't too big to read carefully.
-
The CI pipeline. Static analysis tools that flag issues after the code is written, often with so many false positives that developers stop reading the output.
None of these prevent insecure code from being written in the first place. They're all reactive. The damage is already done by the time someone catches FromSqlRaw with string concatenation.
What if the tool writing the code just... already knew the rules?
That's what Claude Code skills do. And the security skill takes about 15 minutes to set up. It saves hours of code review comments and prevents entire categories of vulnerabilities from ever entering your codebase.
1 min read
What Are Skills (30-Second Version)
A skill is a SKILL.md file with YAML frontmatter. Claude reads it automatically when the task matches the description. That's it.
name: security
description: Use ALWAYS when writing any code.1 min read
Your Security Rules Here
The `description` field is the trigger. When it says "Use ALWAYS when writing any code," Claude loads those instructions on every single task. No slash command needed. No manual invocation. It just applies.
I wrote a full deep dive on skills in [Claude Skills: What They Are, Why You Need Them, and How to Set Them Up](/blog/claude-skills-setup-guide-why-you-need-them). This post is specifically about what I put *in* the security skill and why.
1 min read
The Security Skill I Built
The frontmatter is the most important part:
name: security
description: Use ALWAYS when writing any code. Enforce OWASP Top 10
protections, secure coding patterns, input validation, and vulnerability
prevention in .NET, Blazor, Razor, JavaScript, and all web code.Two things matter here:
"Use ALWAYS when writing any code." Most skills trigger on specific tasks ("Use when reviewing pull requests" or "Use when writing frontend UI"). This one fires every time. Because security isn't a feature you add later. It's a constraint on everything.
The specific framework list. Mentioning ".NET, Blazor, Razor, JavaScript" helps Claude match the skill to the right context. If you're working in a different stack, swap these out. The patterns translate, the framework names are just for matching.
The body of the skill is structured around the OWASP Top 10, with WRONG/RIGHT code examples for each category. Claude doesn't need theory. It needs concrete patterns to follow and anti-patterns to avoid.
3 min read
OWASP Top 10 in Practice
Here are the five rules that catch the most real-world issues. These are directly from the skill, with the exact examples Claude reads.
1. SQL Injection
The most basic vulnerability. Still shows up constantly.
// WRONG — user input concatenated into SQL
var users = db.Users.FromSqlRaw(
$"SELECT * FROM Users WHERE Name = '{name}'"
);
// RIGHT — parameterized (auto-parameterized by FromSqlInterpolated)
var users = db.Users.FromSqlInterpolated(
$"SELECT * FROM Users WHERE Name = {name}"
);
// BEST — use LINQ, no raw SQL needed
var users = db.Users.Where(u => u.Name == name);The skill tells Claude: "NEVER concatenate user input into SQL, commands, or queries. EF Core parameterizes by default. Use it."
With this in the skill, Claude won't generate the first pattern. It defaults to LINQ. If raw SQL is truly needed, it uses FromSqlInterpolated. I've never had to send back a PR comment about SQL injection since.
2. Cross-Site Scripting (XSS)
Razor auto-encodes output by default. The danger is Html.Raw().
// WRONG — renders raw HTML from user input
@Html.Raw(Model.UserComment)
// RIGHT — auto-encoded by Razor
@Model.UserComment
// If you MUST render HTML, sanitize first
@Html.Raw(sanitizer.Sanitize(Model.UserComment))In Blazor, MarkupString is the equivalent of Html.Raw. Same danger, different syntax. The skill calls both out explicitly so Claude catches it regardless of which rendering model you're using.
3. Cross-Site Request Forgery (CSRF)
One missing attribute. One exploitable endpoint.
// WRONG — no anti-forgery token
[HttpPost]
public async Task<IActionResult> Delete(int id) { ... }
// RIGHT — always validate the token
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id) { ... }The skill says: "ALWAYS use [ValidateAntiForgeryToken] on POST/PUT/DELETE actions. NEVER skip this." Short, direct, no room for interpretation.
Every controller action Claude generates for me now includes this attribute. Before the skill, it was about 50/50.
4. Mass Assignment / Over-Posting
This is the one that surprises people. It's a .NET-specific vulnerability that's easy to miss.
// WRONG — user can POST IsAdmin=true
public async Task<IActionResult> Create(User user) { ... }
// RIGHT — only bind expected fields via DTO
public async Task<IActionResult> Create(CreateUserDto dto) { ... }If you bind directly to your domain model, any property on that model is fair game. A user can add IsAdmin=true to the POST body and your framework will happily set it.
The fix is simple: always use DTOs (Data Transfer Objects) that only contain the fields you expect. The skill enforces this pattern by telling Claude to never bind directly to domain models.
5. Insecure Direct Object References (IDOR)
The "anyone can edit anything" vulnerability.
// WRONG — no ownership check
public async Task<IActionResult> Edit(int id)
=> View(await _service.GetByIdAsync(id));
// RIGHT — verify the user owns/can access this resource
public async Task<IActionResult> Edit(int id)
{
var user = await _service.GetByIdAsync(id);
if (user is null) return NotFound();
if (user.Id != CurrentUserId && !User.IsInRole("Admin"))
return Forbid();
return View(user);
}Just because /users/123/edit exists doesn't mean the person requesting it should have access. The skill tells Claude to always verify ownership, not just existence.
What Else the Skill Covers
Beyond these five, the full skill also enforces:
- Security headers (X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy)
- Input validation (Data Annotations, FluentValidation, file upload checking)
- Secrets management (never hardcode, use
dotnet user-secretslocally, Key Vault in production) - Logging safety (never log passwords, tokens, or PII)
- Unvalidated redirects (always check
Url.IsLocalUrlbefore redirecting) - Broken authentication (ASP.NET Identity, account lockout, strong password policies)
- Dependency security (
dotnet list package --vulnerable)
Each one has the same format: the rule, a WRONG example, a RIGHT example. Claude doesn't need paragraphs of explanation. It needs patterns.
2 min read
Accessibility Is Security Too
I built a second skill alongside the security one. The frontend-styling skill includes a full WCAG 2.1 AA section that applies to every view and component.
Here's why I consider it part of the same effort: if your app is unusable for 15-20% of your users, you have a product failure. And if you're in an enterprise, legal, or government context, you have a compliance failure. Accessibility isn't optional and it isn't a nice-to-have.
The accessibility rules follow the same WRONG/RIGHT pattern:
Forms Without Labels
<!-- WRONG — input has no associated label -->
<input type="email" class="form-control" />
<!-- RIGHT — label with matching for/id -->
<label for="email" class="form-label">
Email <span class="text-danger" aria-hidden="true">*</span>
</label>
<input id="email" type="email" class="form-control"
autocomplete="email" aria-required="true"
aria-describedby="email-error" />
<span id="email-error" class="text-danger small" role="alert"></span>Icon-Only Buttons
<!-- WRONG — screen reader has no idea what this does -->
<button class="btn btn-sm">
<i class="bi bi-pencil"></i>
</button>
<!-- RIGHT — aria-label describes the action -->
<button class="btn btn-sm" aria-label="Edit user John Smith">
<i class="bi bi-pencil" aria-hidden="true"></i>
</button>Color as the Only Indicator
<!-- WRONG — color is the only way to tell this is an error -->
<span class="text-danger">Error</span>
<!-- RIGHT — icon + color + role for screen readers -->
<span class="text-danger" role="alert">
<i class="bi bi-exclamation-triangle me-1" aria-hidden="true"></i>
Error: Email is required
</span>The skill also enforces semantic HTML (<main>, <nav>, <header>, <footer>), heading hierarchy (never skip levels), keyboard navigation, prefers-reduced-motion support, and minimum touch targets (44x44px).
Same as security: Claude doesn't forget these rules. Once they're in the skill, they apply to every component, every view, every page. No more "we'll add accessibility later."