Get listed

Agile security: put the work in the sprint, not a later phase

A sprint board with one coral sticky note falling off.

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 exportSEC-1842 with retestId
Security phase after GAsame sprint as the route
Happy-path JestBob fixture expects 404
CodePipeline is greennamed 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.

The feature card blocks on the deny card. Later is not a third state on this path.
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.

  1. Commit sprint-14.yml, SEC-1842 fields, the Jest file, and hatch-grep.yml.
  2. Open a PR that adds exportInvoice without canInvoice and without the Bob test. Expect Jest to fail or the file to be missing.
  3. Add canInvoice plus the Bob fixture. Expect 404 for Bob and 200 for Alice.
  4. Turn on the required check named hatch-grep. Merge a red job. Expect the host to refuse.
  5. Grep the tree for SEC-1842, canInvoice, and hatch-grep so 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.

Aphinya Dechalert

Aphinya Dechalert / About Author

Aphinya is a skilled technical writer with field experiences in software development, agile, and JavaScript full stack with AWS and Google cloud. She is a developer advocate and community builder, helping others navigate their journeys and careers as developers.