
Agile does not make a feature secure. A story that never includes the fail path will ship the happy path.
If ‘export invoice’ sits in Later for eleven sprints and then merges with one Jest, you shipped a download that nobody authorized. Velocity hid the gap.
The usual mistake is a definition of done that says ‘tested’ and means ‘the button works for me.’
This page is how to put the object check and the negative test in the same story as the feature, before the sprint ends.
The canInvoice story sat in Later for 11 sprints. Sprint 14 merged exportInvoice with one happy-path Jest. Bob’s fixture still returned 200 and Alice’s cents. A phase named Harden after GA is the same column with a calendar invite.
Speed is not the bug. The bug is a feature ticket that can close while the deny ticket stays in Later. Pair this page with the secure coding checklist for the control name, the IDOR guide for the helper, and the injection guide for the hatch the job greps.
A Later column is how the export shipped
OWASP published ASVS 5.0.0 live on 30 May 2025 at Global AppSec EU Barcelona. A sprint that ships GET /invoices/:invoiceId/export.csv without a V8 deny test has treated access control as a later phase. The standard is not a ceremony after freeze. It is the acceptance line on the feature ticket.
Agile as a word does not threaten a bind. A board that only scores user-visible cards does. The export is user-visible. Bob reading Alice is also user-visible, just not in the demo script. If the demo script is the definition of done, the deny never gets a point value, so it never enters the sprint.
I am not asking for a security sprint after the feature sprints. That is the phase with a new hat. I am asking for the deny test to sit on the feature card that adds the route. If the card is too big, split the route out. Do not split the control off and file it under Tech Debt.
Write the ticket the sprint can close
A ticket that says “harden export” cannot close. A ticket that names the file, canInvoice, and the Jest path can. Four fields. If a field is empty, keep the card out of planning.
{
"ticketId": "SEC-1842",
"sprint": 14,
"hatchId": "http-invoice-export",
"change": "GET /invoices/:invoiceId/export.csv",
"file": "src/routes/invoice.js",
"symbol": "exportInvoice",
"control": "canInvoice",
"asvs": "v5.0.0-8.2.2",
"retestId": "test/export-invoice-idor.test.js",
"points": 3,
"ownerId": "invoice-oncall",
"doneWhen": [
"canInvoice called before renderCsv",
"asBob against aliceInvoiceId returns 404",
"hatch-grep job required on the PR"
]
}
Copy those fields into Jira, Linear, or a Markdown file in the repo. The format is not the control. The closed card is. points: 3 is whatever your board uses. The number exists so the card competes with the demo work in the same planning hour. A card with no points stays in Later. That was sprint 1 through 13.
When a review finds a miss after merge, open one follow-up that can close. Reuse hatchId. Point at the same retestId. That is how a production miss becomes sprint 15 work instead of a slide titled Lessons. NIST SSDF 1.1, published February 2022, calls this RV.1. As of 22 August 2026, CSRC final page. The 1.2 draft from 17 December 2025 is still a draft on that page. Map the ticket to 1.1 until a final lands.
Done means the deny test is green
Definition of done on invoice-app is not “QA clicked export.” It is the Bob fixture. Happy-path Jest proves the feature. It does not prove the boundary. CWE-639 is the name for the miss. The test is the proof.
// test/export-invoice-idor.test.js
test("bob cannot export alice invoice", async () => {
const res = await asBob.get("/invoices/" + aliceInvoiceId + "/export.csv");
expect(res.status).toBe(404);
});
test("alice can export alice invoice", async () => {
const res = await asAlice.get("/invoices/" + aliceInvoiceId + "/export.csv");
expect(res.status).toBe(200);
});
// src/routes/invoice.js
async function exportInvoice(req, res) {
const actor = requireUser(req);
if (!actor) return res.status(401).end();
const invoice = await queryInvoice(req.params.invoiceId);
if (!invoice || !canInvoice(actor, invoice)) {
return res.status(404).end();
}
return res.send(await renderCsv(invoice.id));
}
function canInvoice(actor, invoice) {
if (actor.role === "admin") return true;
return invoice.ownerId === actor.userId;
}
Identifiers stay SEC-1842, exportInvoice, canInvoice, and export-invoice-idor.test.js. The pull request that adds the route adds the test. A reviewer who sees only the happy path sends the card back. That is not a phase. That is the same review you already do for a missing migration.
Sort tokens and raw SQL belong with the route when it introduces them. Add queryInvoiceList and the hatch job to doneWhen. Input validation is the door if the body can be an object where you expected a string. Put that parse on the card too. Do not open a sibling issue titled “validation later.”
| Board lie | Card that can close |
|---|---|
| Harden export | SEC-1842 with retestId |
| Security phase after GA | same sprint as the route |
| Happy-path Jest | Bob fixture expects 404 |
| CodePipeline is green | named job hatch-grep required |
Capacity is a row, not a speech
Planning speeches do not reserve hours. A row in the sprint file does. Take one card’s points from the demo pile and give them to SEC-1842 until the deny exists. If the export cannot fit with its deny, leave the export in the backlog. That sentence is the whole method.
# sprint-14.yml
sprint: 14
app: invoice-app
velocity: 21
cards:
- id: INV-220
title: CSV export for owners
points: 5
blockedBy: SEC-1842
- id: SEC-1842
title: canInvoice on exportInvoice
points: 3
control: canInvoice
retestId: test/export-invoice-idor.test.js
- id: INV-221
title: export email footer
points: 2
INV-220 cannot merge without SEC-1842. The footer card can. That is how you keep shipping visible work without pretending the object check was free. If management pulls SEC-1842 mid-sprint, INV-220 leaves with it. Do not silently keep the route.
Standup asks whether SEC-1842 is still blocked, not whether someone “looked at security.” If the Bob fixture is red, the export is not done. If hatch-grep is not a required check yet, that is a card for the same increment, not a hallway promise. The next hire should be able to open sprint-14.yml and see the block. A wiki page titled DevSecOps does not appear in that file.
TICKET SEC-1842 control canInvoice retest export-idor | v SPRINT 14 points 3 INV-220 blockedBy | v PR exportInvoice deny test in same diff | v DONE asBob -> 404 hatch-grep required
The 2025 global average breach cost was $4.44 million. That number does not belong on the sprint whiteboard as motivation. It belongs nowhere on this page except as a dated fact you can ignore. The card is the control. A poster about millions will not move SEC-1842 out of Later. Points and a block will.
The pipeline is the named job
A pipeline that does not run a deny test is a faster way to ship Bob’s 200. The host can be GitHub Actions, CodePipeline, or a runner you own. The requirement is the job name and the required check.
#.github/workflows/hatch-grep.yml
name: hatch-grep
on:
pull_request:
branches: [main]
jobs:
hatch-grep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: deny-tests
run: npx jest test/export-invoice-idor.test.js
- name: hatch-grep
run: bash tools/hatch_grep.sh
# tools/hatch_grep.sh
set -euo pipefail
PAT='Sequelize\.literal|whereRaw|\$queryRawUnsafe|knex\.raw\('
rg -n -e "$PAT" --glob '!node_modules' --glob '!allow-hatches.txt' src \
| sort > /tmp/hatch-hits.txt
touch allow-hatches.txt
sort -u allow-hatches.txt > /tmp/hatch-allow.txt
if ! comm -13 /tmp/hatch-allow.txt /tmp/hatch-hits.txt | grep -q.; then
exit 0
fi
echo "new hatch string. add a reviewed allow line or remove the call"
comm -13 /tmp/hatch-allow.txt /tmp/hatch-hits.txt
exit 1
Require the check named hatch-grep on main. A green CodePipeline stage that only builds the container is not this job. Actions checkout v5 is the current major on 22 August 2026. Pin it. Do not copy a 2021 CodePipeline screenshot and call the sprint done.
If you already have CodePipeline, add a CodeBuild step that runs the same two commands. The console walkthrough is optional. The required status is not. A merge that can ignore a red deny test is how sprint 14 shipped.
Prove the sprint yourself
You are proving a route cannot merge without the deny card, and that Later is not a third state. Use a repo you own.
- Commit
sprint-14.yml,SEC-1842fields, the Jest file, andhatch-grep.yml. - Open a PR that adds
exportInvoicewithoutcanInvoiceand without the Bob test. Expect Jest to fail or the file to be missing. - Add
canInvoiceplus the Bob fixture. Expect 404 for Bob and 200 for Alice. - Turn on the required check named
hatch-grep. Merge a red job. Expect the host to refuse. - Grep the tree for
SEC-1842,canInvoice, andhatch-grepso planning next sprint starts from those names.
rg -n "SEC-1842|canInvoice|hatch-grep|export-invoice-idor" \
--glob '!node_modules'
If INV-220 merged while SEC-1842 is still in Later, the board lied. If the Jest file exists and is not in CI, the card lied. Identifiers stay SEC-1842, exportInvoice, canInvoice, and hatch-grep.
A phase plan that lists Threat Model, then Build, then Pen Test, will keep producing the 11-sprint gap. Threat notes belong on the card before the hatch lands. Build is the helper. The Bob fixture is the proof. An external test week can still happen. It does not replace the card. If the only security work in the increment is “talk to AppSec next quarter,” you have a phase again. Put AppSec on the review of SEC-1842 this increment, or write that you shipped without that review.
Questions we keep getting
Do we need a security sprint every quarter?
No. You need the deny test attached to the route card. A quarterly hardening week is a phase with snacks. Use it only for work that has no feature card, such as rotating an old hatch allow line. Daily exports do not wait for that week.
What if product refuses to point the deny card?
Then keep the route out of this increment. Write that on the board. Shipping the CSV without canInvoice is a decision. It should be visible. Hiding it in Later is how phatfish’s backlog story starts.
Is AWS CodePipeline required?
No.Any host that can require hatch-grep is enough. If you already have CodePipeline, add the Jest and grep steps. Do not spend the sprint connecting GitHub Version 2 unless that connection is itself the ticket.



