Security Acceptance Criteria in Agile Tickets

Most security requirements fail at the same point: they are written as principles. “Input must be validated”, “access must be controlled”, “data must be protected” are unarguable, unimplementable and unverifiable. They pass review because nobody can demonstrate they are unmet, and they leave nothing behind in the test suite.

A useful criterion names an observable outcome for this feature and is checkable by someone who did not write the code. This guide covers how to write them, how to bind each to a test so the pair stays honest, and how to record the cases you deliberately decide not to meet. It is part of the Security Requirements & Abuse Cases guide within Threat Modeling Fundamentals & Methodology.

Prerequisites

  • Abuse cases produced during refinement — see writing abuse cases for user stories
  • A test suite that runs on every pull request
  • A definition of done the team applies to close tickets
  • A place to record accepted risks that is reviewed on a schedule

Expected Outcomes

  • Criteria that state observable outcomes rather than principles
  • A named test for every security criterion, referenced from the ticket
  • Security criteria blocking done exactly as functional criteria do
  • Accepted risks recorded with an owner and a review date

Step 1: Rewrite Principles as Outcomes

The transformation is mechanical once you see it: replace the property with the behaviour that demonstrates it.

Principles on the Left, Criteria on the Right Four pairs. Validate all input becomes a specific request shape returning a specific status. Access must be controlled becomes a named role receiving a named status on a named route. Data must be protected becomes a field absent from a specific response. Rate limiting must be applied becomes a count, a window and a status code. Principle — unverifiable Criterion — observable, and therefore testable "Validate all user input" A request with an undeclared field returns 400 naming the field; no undeclared field ever reaches the query layer. "Access must be controlled" A viewer calling the export route receives 403; a member of another workspace receives 404. "Sensitive data is protected" The member list response contains no email address unless the caller holds the admin role. "Apply rate limiting" The 21st invite in a day returns 429 with a Retry-After header.

A useful test for any candidate criterion: could a new engineer, with no knowledge of the implementation, determine whether it holds by interacting with the running system? If not, it is a principle and needs rewriting.


Step 2: Bind Each Criterion to a Named Test

## Acceptance criteria (security)
- [ ] A request with an undeclared field returns 400 naming the field
      → `test_rejects_undeclared_fields`
- [ ] A viewer calling the export route receives 403
      → `test_viewer_cannot_export`
- [ ] A member of another workspace receives 404 for any report id
      → `test_cross_workspace_report_is_not_found`
- [ ] The 21st invite within 24 hours returns 429 with Retry-After
      → `test_invite_rate_limit_per_member`
# The other half of the binding: the test names the criterion it satisfies.
def test_cross_workspace_report_is_not_found(client, alice, bobs_report):
    """AC: a member of another workspace receives 404 for any report id (TICKET-4187)."""
    res = client.get(f"/api/reports/{bobs_report.id}", as_user=alice)
    assert res.status_code == 404
    assert bobs_report.title not in res.text

The reciprocal reference is what keeps the pair from drifting. When someone deletes the test, the ticket’s criterion is orphaned and the CI check notices; when someone changes the criterion, the test’s docstring no longer matches and review notices.


Step 3: Keep Them in the Definition of Done

## Definition of done — TICKET-4187
- [x] Functional acceptance criteria pass
- [x] Security acceptance criteria pass, each with a linked test
- [x] Baseline BL-UC applied and referenced in the pull request
- [ ] Accepted risks recorded with owner and review date        ← blocks done

The list has to be one list. A separate “security checklist” section is a separate thing to skip when the sprint is tight, and the person moving the ticket will skip it precisely when the pressure that makes mistakes likely is highest. Mixed in, every criterion gets the same glance.

One List Gets Read; Two Lists Get Prioritised When functional and security criteria share one list, whoever closes the ticket has to look at all of them. When security criteria sit in their own section, that section becomes the thing skipped when the sprint is tight — which is precisely when mistakes are most likely. One mixed list invite email is delivered within 60 seconds invite token is single use and expires invite list is paginated at 50 per page response is identical for unknown addresses Every line gets the same glance. A separate security section functional criteria — read and checked Security criteria (collapsed) skipped at 17:40 on a Friday A separate section is a separate thing to skip.

Step 4: Record What You Knowingly Accept

# risks/accepted/2026-07-31-invite-rate-limit.yml
id: RISK-2026-014
ticket: TICKET-4187
criterion: "The 21st invite within 24 hours returns 429 with Retry-After"
status: accepted
reason: >
  The limiter service is not yet available in the invite path. Invitations are
  currently capped at the mail provider's own send limit, which is 500 per day
  per workspace — higher than we want, but not unbounded.
compensating_control: "Daily alert on any workspace exceeding 100 invitations"
owner: "priya.n"
accepted_on: 2026-07-31
review_by: 2026-09-15

Two fields make this a decision rather than an excuse: a named owner, and a date on which someone will look again. A risk register with neither is a list of things nobody is going to fix, and everyone involved knows it.

Three Things That Can Happen to a Criterion A criterion can be met, in which case a named test asserts it. It can be accepted as a risk, in which case a record carries an owner, a reason, a compensating control and a review date. It can be deleted, which converts a tracked decision into an assumption nobody will revisit — the only outcome that is never acceptable. Met a named test asserts it the test runs on every change the ticket links to the test A regression later shows up as a red build, not an incident. Accepted as a risk recorded with a named owner a reason and any compensation a date for the next look A legitimate outcome — teams ship with known gaps, knowingly. Deleted the criterion simply disappears no record, no owner, no date nobody revisits it Never acceptable: it converts a decision into an assumption.

Verification

# 1. Every security criterion in a closed ticket references a test that exists.
python3 tools/check_criteria_have_tests.py --tickets .tickets/closed --tests tests/

# 2. Every accepted risk has an owner and an unexpired review date.
python3 - <<'PY'
import glob, yaml, datetime
today = datetime.date.today()
for path in glob.glob("risks/accepted/*.yml"):
    r = yaml.safe_load(open(path))
    assert r.get("owner"), f"{path}: no owner"
    assert r.get("review_by"), f"{path}: no review date"
    if r["review_by"] < today:
        print(f"OVERDUE {r['id']} ({r['review_by']}) owner={r['owner']}")
PY

# 3. Some criteria caused a test to fail before the feature shipped.
git log --since='90 days ago' --grep='AC:' --oneline | head

Troubleshooting

Symptom Likely cause Fix
Criteria are always marked met without discussion They are principles, not outcomes Rewrite each as an input and an observable result
Tests exist but nobody knows which criterion they serve One-way linking only Reference the criterion in the test docstring and the test in the ticket
Security criteria are skipped when the sprint is tight They sit in a separate section Merge them into the single acceptance criteria list
The risk register fills with stale entries No review date or no owner Require both; fail the check on overdue entries
Criteria drift from what shipped Written before design, never revised Revisit them at pull-request time and update both sides together

Common Implementation Mistakes


Frequently Asked Questions

What makes a criterion testable rather than aspirational?

It names an input and an observable result. “Validates input properly” is aspirational; “a request containing an undeclared field returns 400 with the field name in the error body” is testable. The fastest check is whether two engineers would reach the same verdict just by exercising the running system — if they would end up arguing about interpretation, the criterion needs rewriting before it goes into the ticket.

Should security criteria live in the ticket or in a separate document?

In the ticket, in the same list as everything else. A separate security section is a separate thing to skip, and separate documents get read by the people who already care rather than the person closing the ticket at five o’clock on a Friday. Mixing them means whoever moves the ticket to done has to look at all of them, and that glance is the entire enforcement mechanism.

How do you handle a criterion that cannot be met this sprint?

Record it as an accepted risk with a named owner, a review date, and a statement of any compensating control. That is a perfectly legitimate outcome — teams ship with known gaps for good business reasons, and pretending otherwise just drives the gaps underground. What is never legitimate is deleting the criterion, because that turns a tracked decision into an assumption that nobody will ever revisit.