top of page

Security Testing Fundamentals for Developers

  • Shawn West
  • Mar 25
  • 11 min read

Updated: Aug 10

A customer emailed support with a screenshot. The invoice on their screen wasn't theirs — different company name, different amount, a stranger's billing address. They hadn't done anything clever. They'd bookmarked their invoice page, /invoices/4187, and one morning they typed /invoices/4188 out of idle curiosity. The app handed them the next customer's document without a flicker.

The billing feature had shipped four months earlier. It passed code review. It passed the static analysis scan wired into the pipeline. It sat through two releases. Then a pen-test firm the company hired for a compliance checkbox found the same bug in an afternoon, wrote it up as a high-severity finding, and billed for the privilege. By then a real customer had already stumbled into it.

Nothing about this was exotic. The endpoint looked up the invoice by the ID in the URL and returned it. It never asked the one question that mattered: does the person making this request own this invoice? That question is a single line of code and a five-line test. The reason it never got asked is the reason most security bugs ship — security was somebody else's job, scheduled for after the work was done, instead of a behavior the team verified the way they verify everything else.

This piece is about closing that gap. Not by hiring the pen-test firm sooner, but by treating security the way you already treat correctness: as behavior the normal test suite checks on every push. We'll walk the four testing layers most teams reach for, trace one real bug through all of them to see which layer actually catches it, and end with tests and config you can turn on this week.

Why "test it at the end" feels reasonable

Security-as-a-final-gate is not a stupid idea. It's an organizational convenience that hardened into a habit. Specialized security engineers are scarce, so their attention gets rationed to a review near release. Their tools — dynamic scanners, pen tests — genuinely need a deployed, running application to work against, which biases them toward the end of the cycle. And a lot of security is specialized: exploit chains, cryptographic protocol flaws, and business-logic abuse reward deep expertise.

The trap is generalizing from "some security work is specialist" to "all security work is specialist, so developers wait." Most of what actually breaks in web applications isn't an exotic cryptographic flaw. It's an endpoint that forgot to check ownership, a form that trusts its input, a login that never rate-limits. The Open Web Application Security Project (OWASP) ranks Broken Access Control as the number-one category in its 2021 Top 10 — the invoice bug above, exactly. These are correctness bugs that happen to have a security blast radius, and correctness bugs are what your test suite exists to catch.

This week: open your last three security findings — from a scanner, a pen test, or an incident. For each, ask whether a developer could have written a plain assertion that would have failed on the bug. Count how many. That number is your case for shifting the work left.

The four layers, and what each one can and cannot see

Four techniques dominate developer-facing security testing. They are not competitors; they see different things, and the failures of one are the reason the next exists.

SAST (Static Application Security Testing) reads your source code without running it, matching patterns that tend to be dangerous: string-concatenated SQL, unescaped output, hard-coded secrets, weak crypto calls. It's fast and cheap enough to run on every pull request, and it catches whole classes of bug at once. Its weakness is that it doesn't understand your intent. It sees syntax, not the runtime, so it's noisy with false positives and blind to anything that depends on state, identity, or configuration.

DAST (Dynamic Application Security Testing) attacks a running instance from the outside, firing malformed and malicious inputs at real endpoints and watching how they respond. Because it exercises the deployed system, it catches configuration mistakes, missing headers, and injection that only manifests at runtime. Its weakness is coverage: it only tests what it can reach and authenticate into, it's slower, and it rarely knows what a correct response would have been — so it misses bugs where the app cheerfully returns the wrong data.

Dependency scanning ignores your code entirely and inventories the third-party libraries you pulled in, cross-referencing them against known-vulnerability databases. Modern applications are mostly other people's code, and that code has published CVEs, so this is one of the highest-leverage checks available. Its weakness is that a vulnerability present in a library isn't always reachable from your app, so it over-reports and demands triage.

Penetration testing is humans — skilled ones — actively trying to break in. People chain small weaknesses into real exploits and reason about business logic in ways no scanner does. The weakness is economics: it's expensive, time-boxed, its quality varies with the team, and it happens a few times a year at most, which makes it a terrible place to catch a bug you introduced on a Tuesday.

Here's the same picture as a decision table:

Layer

Catches

Misses

When to run

SAST

Injection, unescaped output, hard-coded secrets, weak crypto — whole classes, from source

Access-control and business-logic flaws, runtime/config issues, anything depending on identity or state

Every pull request, in CI, blocking on high-confidence rules

DAST

Runtime and config faults, missing security headers, reachable injection, TLS mistakes

Bugs behind auth it can't reach, logic errors where a wrong-but-valid response looks fine

Against staging, weekly and pre-release — not every commit

Dependency scanning

Known CVEs in third-party libraries and transitive dependencies

Vulnerabilities in your code; flags issues in code paths you never call

Continuously; triage weekly, patch critical CVEs immediately

Penetration testing

Exploit chains, business-logic abuse, creative multi-step attacks

Anything introduced after the engagement; regressions between tests

Annually at minimum, before major releases, after architecture changes

This week: map your current pipeline against these four rows. Most teams have one or two and a blind spot where the others should be. Name your blind spot out loud in your next standup — that's the whole diagnostic.

One bug, four layers: which one actually catches it

(Developed example — composite scenario.)

Take the invoice bug and push it through every layer, because the instructive part isn't what each layer does in theory — it's watching three of them wave it through.

The offending handler looks like ordinary, reviewed code:

@app.get("/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user = Depends(current_user)):
    invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
    if invoice is None:
        raise HTTPException(status_code=404)
    return invoice

It authenticates the caller — current_user guarantees someone is logged in. It just never checks that this someone owns this invoice. That single missing predicate is the entire vulnerability.

SAST walks right past it. There's no dangerous pattern here. No string-built SQL — the ORM parameterizes the query. No unescaped output, no secret, no weak cipher. The code is, syntactically, clean. The bug is a missing check, and static analysis is far better at spotting a bad line than an absent one. This is the core limitation: SAST reasons about the code you wrote, not the authorization rule you forgot. It cannot know that invoice 4188 belongs to a different tenant, because ownership is a fact about runtime data, not about syntax.

DAST might catch it, and probably won't. A dynamic scanner authenticated as one test user, crawling the app, would request /invoices/{id} for IDs it had seen. But 4188 belongs to a different account it was never given, and the response — a perfectly well-formed invoice, HTTP 200 — looks successful. The scanner has no model of who should be able to see that document, so nothing trips. Specialized tooling for exactly this (authenticated scans with two accounts, comparing what each can reach) exists and can find it, but it's configuration you have to deliberately set up, not a default a passing scan gives you for free.

Dependency scanning is irrelevant here — and it's worth saying plainly. The bug is in first-party code. No library is at fault, no CVE applies. Dependency scanning would have caught a different invoice bug — say, a PDF-rendering library with a known remote-code-execution advisory — and missed this one entirely. It's a vital layer aimed at a different target.

A pen test catches it — months and dollars later. A human logs in as two accounts, notices sequential integer IDs, swaps one, and sees another tenant's data. Ten minutes of work. But that human arrives on a schedule set by procurement, long after the code shipped, which is why the real customer found it first.

Now the layer nobody listed, because it isn't a scanner — the normal test suite. Authorization is a behavior, and behaviors get asserted:

def test_user_cannot_read_another_users_invoice(client):
    alice = create_user()
    bob = create_user()
    alice_invoice = create_invoice(owner=alice, amount=500)

    # Bob is fully authenticated — as himself.
    response = client.get(
        f"/invoices/{alice_invoice.id}",
        headers=auth_header_for(bob),
    )

    # Owning a valid session is not the same as owning the resource.
    assert response.status_code in (403, 404)
    assert b"500" not in response.content   # no data leak in the body

This test fails the instant the handler ships without an ownership check. It runs in milliseconds, on every push, next to the tests that check the invoice total is correct — because to the suite, "Bob can't read Alice's invoice" is the same kind of claim as "the total is the sum of line items." Both are behaviors the code either has or doesn't. The fix the failing test demands is one clause:

    if invoice.owner_id != user.id:
        raise HTTPException(status_code=404)   # 404, not 403 — don't confirm it exists

The lesson isn't that scanners are useless. It's that the layer positioned to catch the most common vulnerability class cheapest and earliest is the one you already own. SAST, DAST, and dependency scanning ring the perimeter; the access-control bug walks through the middle, where only a test that knows your domain is standing.

This week: find one endpoint that returns a record by ID. Write the two-user test above against it. If it passes, you've verified a real control. If it fails, you found a live bug before a customer did.

Security is a behavior — so put it in the suite

Once you stop treating security as a separate discipline and start treating it as behavior, a short list of tests covers a large share of real-world web vulnerabilities. Each maps to an OWASP Top 10 category, and each is an ordinary assertion:

  • Authentication — a signed-out request to a protected route returns 401, never data. (Assert the redirect or the 401, and assert the body is empty.)

  • Authorization — user B cannot read, edit, or delete user A's resource, even fully logged in. The invoice test above. (This is the number-one category; write these first.)

  • Input validation — an over-long, wrong-typed, or script-laced field is rejected or safely escaped, not stored raw and reflected back.

  • Rate limiting — the eleventh rapid login attempt is throttled, so credential-stuffing and brute force hit a wall.

  • CSRF — a state-changing POST without a valid anti-forgery token is refused.

None of these needs a security tool. They need the test framework you're already running. The point of listing them is that they're checkable — each one is a behavior with a pass and a fail, not a principle to nod along to.

This week: add the authentication test and the authorization test to one protected feature. Those two categories cover the most frequently exploited failures on the OWASP list, and they're the least effort per bug prevented.

The discovery move: threat-model before you write tests

You can't write every security test, and evenly spreading effort is its own failure — you end up with thorough input-validation tests on a marketing form and none on the money. The prioritization comes from a lightweight threat model, and it's the discovery step that most teams skip. It's four questions, answered in twenty minutes on a whiteboard, applied to one concrete system.

Take the billing service from the opening. Run it through the questions:

  1. What are we protecting? Invoices, payment methods, and the mapping of customers to their financial data. Not "the app" — the specific assets whose exposure hurts.

  2. Who might attack it? A logged-in customer poking at other customers' data (the realistic, common case). A credential-stuffer with a list of leaked passwords. Far less likely, a sophisticated external attacker.

  3. What could they do? Read another tenant's invoices by changing an ID. Guess passwords against the login. Tamper with an amount in a request body.

  4. What's the impact? Cross-tenant financial data exposure is a breach with contractual and regulatory weight. A throttled login is an annoyance. The impacts are not equal.

That twenty minutes orders your test backlog. The exercise says, in writing, that cross-tenant authorization on invoices is the highest-impact, most-likely failure — so the two-user authorization test gets written first, before the input-validation tests on the settings form. The threat model is what turns "we should test security" into "we test this first, and here's why." An internal tool used by five trusted employees and a public payment processor deserve completely different test suites, and this is the step that tells you which one you're building.

This week: run these four questions on the single most sensitive feature you own. Write the first test against the highest-impact, most-likely row. You've now prioritized security work with evidence instead of vibes — the discovery-first move applied to security.

Wire the cheap layers into CI

Two of the four layers cost almost nothing to automate and should run without anyone thinking about them.

SAST on every pull request. Semgrep is a common choice; run it in CI and block only on high-confidence rules so the pipeline doesn't cry wolf:

# .github/workflows/sast.yml
name: sast
on: [pull_request]
jobs:
  semgrep:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: returntocorp/semgrep-action@v1
        with:
          config: p/owasp-top-ten   # curated ruleset, not everything
        # fail the build on findings so a real hit blocks the merge

Start with a curated ruleset like p/owasp-top-ten rather than every rule Semgrep ships, or the false-positive volume will train the team to ignore it — the fastest way to make a security tool worthless.

Dependency scanning, always on. If you're on GitHub, Dependabot is a config file and a checkbox:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "pip"     # match your stack: npm, maven, gomod, ...
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10

It opens pull requests as new CVEs land in your dependencies. The discipline is triage: patch critical, reachable advisories immediately; batch the rest weekly so the PRs don't pile into noise you stop reading.

This week: commit the Dependabot file. It's the single highest-leverage security change you can make in one commit, because it turns "we'll patch when we hear about it" into a standing feed of fixes. Add the Semgrep workflow next sprint once you've tuned the ruleset.

What to do next

The move is not "adopt four tools." It's to relocate the cheapest, highest-value checks from a gate at the end into the suite you already run:

  1. Write two tests — one authentication, one authorization — against your most sensitive feature. Those cover the most-exploited categories.

  2. Threat-model that feature with the four questions, and let the highest-impact row decide which test you write next.

  3. Turn on Dependabot today and schedule Semgrep with a curated ruleset for next sprint.

  4. Keep DAST and pen testing where they belong — against staging and on a schedule — as the outer ring, not the whole defense.

Security testing follows the same logic as the rest of testing: catch each bug at the lowest, cheapest level where it can be caught, and reserve the expensive, late, specialist checks for the bugs that genuinely need them. For the broader frame on why we test at all, see 'Testing Fundamentals: Why We Test'; for where these authorization checks sit between the layers, 'Integration Testing: When, How, and Why' and 'Unit Testing Best Practices: A Practical Guide' cover the levels these tests live in, and 'End-to-End Testing Without the Pain' covers the full-journey tier above them.

The invoice bug wasn't caught late because it was hard. It was caught late because nobody asked, in the normal course of writing and testing the feature, whether one user could read another's data. That question is a five-line test. Ask it on every feature that touches something worth protecting, and the pen test stops being the first line of defense and becomes the last.

Sources

  • OWASP Top 10 (2021), which ranks Broken Access Control as the number-one web application security risk category — owasp.org/Top10.

  • The invoice scenario is a composite built from common broken-access-control patterns, not a specific incident, and is labeled as such in the text.

Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center.

bottom of page