How to build an effective security code review checklist
We’ve all seen the generic security-focused code review checklist. It has items like “Check for SQL Injection” and “Prevent Cross-Site Scripting.” We drop a link to it in PR templates, and then, in a busy week, we just skim past it. It ends up feeling more like a bureaucratic step than something that actually helps prevent problems.
The issue is that these lists have nothing to do with what we’re actually running. They don’t take into account our frameworks, legacy services, or the business logic that ends up creating vulnerabilities that are harder to spot day to day. In the end, the review process catches only the basics, things a linter would already flag, and lets more critical bugs slip through, the kind that come from the system’s own complexity.
What I’ve been seeing is that the same security mistakes tend to come back. A specific type of authorization bypass gets fixed in one service and shows up again in another six months later. We miss vulnerabilities tied to business rules, like a race condition in an endpoint that grants promotions, because they’re not in any generic OWASP Top 10 list. The result is a process that creates a false sense of security.
The only way to fix this is to build a security-focused code review checklist that reflects the reality of your codebase.
When the generic security code review checklist is not enough
Why default lists don’t catch real issues
Generic checklists fail for predictable reasons. They feel disconnected from the code we write every day. An item like “Validate all inputs” is correct, but not helpful. What does that mean for a Go service receiving protobufs over gRPC? How does that apply to a Next.js frontend talking to a GraphQL API? The advice is so generic that it loses meaning during an actual review.
These lists also don’t reflect our specific threat model. An internal B2B dashboard has different risks than a public payment processing API. A generic checklist treats all systems as if they had the same attack surface, which just isn’t true.
During high-pressure moments, this lack of specificity is what gets them ignored. When a developer has five PRs to review before the end of the day, a vague list with 50 items is the first thing to get skipped. It’s not a lack of concern about security; it’s that the tool isn’t helping get the job done.
How to build a security code review checklist that works for your team
A good checklist comes from the history and architecture of your own system. It’s less of a static document and more of a living record of what the team has learned in practice.
Focusing the security review on real-world risks
The best source of truth for what should go into your checklist is your own failure history. Go to your issue tracker and search for terms like security, vulnerability, bug bounty, CVE, or post-mortem. Look for patterns.
- Do developers repeatedly forget to check tenant permissions in a multi-tenant system?
- Do you keep finding places where user-generated content is rendered without proper escaping?
These recurring problems are your highest-priority checklist items. They represent your team’s specific blind spots. An item like “Verify that the user’s organization ID matches the resource’s organization ID across all /:orgId/ routes” is a thousand times more useful than “Check authorization issues.”
Then, connect checklist items to your actual frameworks and libraries. If your team uses Django, the checklist should mention specific risks like misconfigured middleware or incorrect use of the @permission_classes decorator. If you use Express.js, you might include a check for missing helmet() middleware. This makes the guidance immediately relevant for whoever is reviewing.
Making each security checklist item clear and actionable
For a checklist to be useful, each item needs to pass a simple test: can a developer quickly determine whether the code passes or fails this point? Vague items fail this test. Actionable items point to specific code patterns.
Include good and bad code examples directly in the checklist. This removes ambiguity and speeds up the review process. Instead of just saying what to do, show it.
Bad checklist item:
- Ensure secure SQL queries.
Good checklist item:
- SQL Injection: Confirm that all database queries are built using the ORM’s query builder and not raw SQL with variable interpolation.
- Bad:
db.raw(f"SELECT * FROM users WHERE email = '{user_email}'") - Good:
db.query(User).filter(User.email == user_email).first()
- Bad:
Items should be short. The reviewer needs to understand the point in a few seconds. If more context is needed, link to a more detailed document, but keep the checklist itself direct and clean.
Adding the checklist to your workflow
A long, generic checklist just creates noise. You need to show the right checks at the right time. You can split the checklist into sections and apply them conditionally.
- A main checklist (5–7 items) for every pull request. It should cover the most common and critical mistakes for the team.
- Context-specific checklists for changes related to specific parts of the code. For example, a PR that modifies an authentication service can trigger an
auth-checklist.md. A change in a payment endpoint can trigger apayments-checklist.md.
This can be automated with code ownership rules or path filters in your CI/CD system. For example, a GitHub Action can automatically post the relevant checklist as a comment on a PR when files in the src/auth/ directory are modified.
This turns the checklist from a manual task into an integrated part of the development workflow, acting as a helpful assistant instead of a gatekeeper.
Evolving your security review checklist
The checklist should never be considered “done.” It’s a document that evolves along with the system and the team.
Adjusting based on what you learn
The process of updating the checklist is just as important as the checklist itself.
- When a security incident happens or a critical bug is fixed, the post-mortem should include a required action: “What can we add to the security review checklist to prevent this type of issue from happening again?”
- If a checklist item always passes without discussion and never finds anything, it might just be noise. Consider removing it or making it more specific.
- Actively ask the engineering team for feedback. Is the checklist getting in the way? Is any item confusing? Does it actually help find bugs? Use that feedback to adjust the document.
This continuous feedback loop helps keep the checklist as a high-signal, low-noise tool that actually improves system security.
A starter security review checklist
This is a template to be adapted to your context, adjusted, and filled with the specific details of your architecture, frameworks, and business logic. Use it as a starting point.
Security Code Review Checklist
Download the security code review checklist as a Markdown file and add it to your repository to ensure deep understanding of your project security.
Main checklist (for all pull requests)
- Logging: Does this change log sensitive data (PII, credentials, raw tokens) in plain text?
- [Customize: Link to your company’s data classification policy.]
- Dependencies: Is any new third-party dependency being added? If so, has it been checked for known vulnerabilities and license compatibility?
- [Customize: Link to your dependency scanning tool results, like Snyk or Dependabot.]
- Error handling: Do error messages shown to users leak internal system details (like stack traces, database error codes, internal IPs)?
- Hardcoded secrets: Is there any new secret (API key, password, certificate) hardcoded in the code?
- Bad:
API_KEY = "sk_live_..." - Good:
os.getenv("STRIPE_API_KEY") - [Customize: Link to your secret management tool, like Vault or AWS Secrets Manager.]
- Bad:
Authentication checklist
- Password handling: Are passwords handled in plain text at any point? Are they hashed using a modern, strong algorithm (like Argon2, bcrypt)?
- Token validation: For JWTs, verify the signature, check the expiration claim (
exp), and confirm the algorithm to preventalg: noneattacks. - Brute-force protection: Is there rate limiting on login, password reset, or MFA endpoints?
- Session management: Are session identifiers generated using a cryptographically secure random number generator? Are sessions properly invalidated on logout and password change?
Authorization checklist
- Ownership checks: For any endpoint accessing a resource by ID (like
/api/documents/{doc_id}), is there a check ensuring the authenticated user can access that specific resource?- [Customize: Provide a code snippet with your standard authorization check function.]
- Multi-tenancy: In multi-tenant systems, is every database query and API call filtered by the current user’s
tenant_idororganization_id?- Bad:
db.query(Invoice).filter(Invoice.id == invoice_id).first() - Good:
db.query(Invoice).filter(Invoice.id == invoice_id, Invoice.tenant_id == current_user.tenant_id).first()
- Bad:
- Role checks: Do role-based access checks follow a deny-by-default logic? Is there validation for administrative or privileged actions?
Input validation and output encoding
- SQL Injection: Are all database queries parameterized or built with a safe query builder? Is there any use of raw SQL string formatting?
- Cross-Site Scripting (XSS): Is all user-provided data properly encoded or escaped before being rendered in the UI?
- [Customize: Specify the correct encoding function/library for your template engine, for example, “Use React’s default JSX encoding, not
dangerouslySetInnerHTML.”]
- [Customize: Specify the correct encoding function/library for your template engine, for example, “Use React’s default JSX encoding, not
- Server-Side Request Forgery (SSRF): If the server makes HTTP requests to a user-provided URL, is that URL validated against an allowlist of domains or IP ranges?
- File uploads: Are uploaded files validated for type, size, and name? Are they stored outside the web root and served with a safe
Content-Typeheader?
API security checklist
- Mass Assignment: When updating a model from a request body, are you explicitly specifying which fields can be updated, or passing the entire body without filtering?
- Bad (ex: Rails):
user.update(params[:user]) - Good:
user.update(params.require(:user).permit(:name, :email))
- Bad (ex: Rails):
- Rate Limiting: Are sensitive or high-cost endpoints protected with rate limiting to prevent abuse?
- API versioning: If this change modifies an existing endpoint, does it break compatibility with current clients? Should a new version be created?