Write Security Acceptance Criteria Into User Stories That Pass Review
For developers and tech leads who need stories to ship with audit-ready security requirements instead of retrofitted fixes. This shows a concrete story template, exact acceptance criteria, and repo-enforceable checks so security and compliance are visible in backlog grooming, code review, and CI.
TL;DR — If security is not written as testable acceptance criteria in the story, it will be deferred until audit time and become a support ticket. The fastest fix is to add a small, repeatable security section to every story: data classification, authn/authz, logging, secrets, crypto, abuse controls, and verification commands that can run in CI. Reading time: ~5 min
Goal
When you are done, your team will have a user story template with mandatory security acceptance criteria, plus a lightweight lint/check step that rejects stories missing compliance-relevant requirements before they reach implementation.
Prerequisites
- Access to the repo that stores backlog items or story templates in Markdown/YAML/JSON
- Permission to change pull request templates, issue templates, or story-definition docs
git >= 2.40— check with:
git --version
jq >= 1.6if your stories are JSON-backed — check with:
jq --version
yq >= 4.40if your stories are YAML-backed — check with:
yq --version
ripgrep >= 14for local validation — check with:
rg --version
- Your organization’s control sources at hand: SOC 2, ISO 27001, PCI DSS, HIPAA, internal secure coding standard, or customer security addendum
- One real feature story you are about to refine; do not start with a blank example
Steps
Step 1: Add a mandatory security section to the story template
Create or update your story template file. If your repo uses GitHub-style issue templates, add this file:
mkdir -p .github/ISSUE_TEMPLATE
cat > .github/ISSUE_TEMPLATE/user-story.md <<'EOF'
---
name: User story
about: Feature work with built-in security and compliance criteria
---
## Story
As a <role>
I want <capability>
So that <business outcome>
## Functional acceptance criteria
- [ ]
## Security acceptance criteria
- [ ] Data classification: Public | Internal | Confidential | Regulated = <value>
- [ ] Authn: Anonymous | Session | SSO | MFA required = <value>
- [ ] Authz: allowed roles = <literal roles>
- [ ] Secrets: no secrets in code, logs, client storage, or URLs
- [ ] Logging: security-relevant events logged with user/id/request-id, no sensitive fields
- [ ] Crypto: TLS in transit; at-rest control = <db/disk/provider control>
- [ ] Abuse controls: rate limit / lockout / idempotency = <literal requirement>
- [ ] Data lifecycle: retention = <days>; deletion path = <job/API/manual>
- [ ] Verification commands included below
## Verification
```bash
# add exact commands here
Compliance mapping
- Control IDs: <literal IDs, e.g. CC6.1, A.8.24, PCI 7.2.5> EOF
Success looks like: the template file exists and new stories show a dedicated "Security acceptance criteria" section.
### Step 2: Convert one real story into testable security requirements
Take an actual feature and rewrite vague security asks into literal criteria. Save this example as `docs/story-example.md`:
```bash
mkdir -p docs
cat > docs/story-example.md <<'EOF'
## Story
As a support agent
I want to view a customer profile
So that I can resolve tickets
## Functional acceptance criteria
- [ ] GET /api/customers/{id} returns profile fields needed by support UI
- [ ] UI renders profile within 2 seconds at p95 for cached records
## Security acceptance criteria
- [ ] Data classification: Confidential
- [ ] Authn: SSO session required; MFA enforced by IdP for support-agent group
- [ ] Authz: only roles support-agent and support-manager may call GET /api/customers/{id}
- [ ] Response excludes ssn, full card PAN, password hash, recovery codes, access tokens
- [ ] Logs record actor_user_id, target_customer_id, request_id, decision=allow|deny; logs must not contain email body, ssn, PAN, or access token
- [ ] Transport requires HTTPS; HTTP requests return 301 or 308 to HTTPS
- [ ] Rate limit: 60 requests/minute per user on GET /api/customers/{id}; excess returns 429
- [ ] Retention: access logs retained 365 days; customer export files deleted after 7 days
- [ ] Verification commands included below
## Verification
```bash
curl -sS -o /dev/null -w '%{http_code}\n' http://app.example.internal/api/customers/123
curl -sS -H 'Authorization: Bearer SUPPORT_AGENT_TOKEN' https://app.example.internal/api/customers/123 | jq .
curl -sS -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer BASIC_USER_TOKEN' https://app.example.internal/api/customers/123
seq 1 65 | xargs -I{} -n1 -P8 curl -s -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer SUPPORT_AGENT_TOKEN' https://app.example.internal/api/customers/123 | sort | uniq -c
Compliance mapping
- CC6.1
- CC6.6
- ISO27001 A.8.2
- ISO27001 A.8.24 EOF
Success looks like: every security line is observable, testable, and tied to a role, endpoint, field list, status code, or retention period.
### Step 3: Add a machine-checkable front matter schema for stories
If your team stores stories in YAML, create a schema-like required shape and a sample story file:
```bash
mkdir -p stories
cat > stories/customer-profile.yaml <<'EOF'
id: APP-142
summary: Support agent can view customer profile
security:
data_classification: Confidential
authn: SSO
mfa_required: true
authorized_roles:
- support-agent
- support-manager
sensitive_fields_excluded:
- ssn
- pan
- password_hash
- recovery_codes
- access_token
logging:
required_fields:
- actor_user_id
- target_customer_id
- request_id
- decision
forbidden_fields:
- ssn
- pan
- access_token
transport:
https_required: true
redirect_code: 308
abuse_controls:
rate_limit: 60/min/user
throttle_status: 429
retention:
access_logs_days: 365
export_files_days: 7
compliance_controls:
- CC6.1
- CC6.6
EOF
Success looks like: the story has explicit security keys that can be parsed instead of buried in prose.
Step 4: Add a repo check that fails when required security fields are missing
Create a validation script for YAML-backed stories:
mkdir -p scripts
cat > scripts/check-story-security.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
failed=0
for f in stories/*.yaml; do
yq -e '.security.data_classification' "$f" >/dev/null || { echo "ERROR: $f missing security.data_classification"; failed=1; }
yq -e '.security.authn' "$f" >/dev/null || { echo "ERROR: $f missing security.authn"; failed=1; }
yq -e '.security.authorized_roles | length > 0' "$f" >/dev/null || { echo "ERROR: $f missing security.authorized_roles"; failed=1; }
yq -e '.security.logging.required_fields | length > 0' "$f" >/dev/null || { echo "ERROR: $f missing security.logging.required_fields"; failed=1; }
yq -e '.security.transport.https_required == true' "$f" >/dev/null || { echo "ERROR: $f must set security.transport.https_required=true"; failed=1; }
yq -e '.security.abuse_controls.throttle_status == 429' "$f" >/dev/null || { echo "ERROR: $f must set security.abuse_controls.throttle_status=429"; failed=1; }
yq -e '.security.compliance_controls | length > 0' "$f" >/dev/null || { echo "ERROR: $f missing security.compliance_controls"; failed=1; }
done
exit $failed
EOF
chmod +x scripts/check-story-security.sh
./scripts/check-story-security.sh
Success looks like: the script exits 0 for complete stories, or prints exact missing fields and exits 1.
Example failure shape:
$ ./scripts/check-story-security.sh
ERROR: stories/customer-profile.yaml missing security.logging.required_fields
ERROR: stories/customer-profile.yaml missing security.compliance_controls
$ echo $?
1
Step 5: Wire the check into CI or pre-merge validation
If you use a shell-based CI step, add this exact command to your pipeline:
./scripts/check-story-security.sh
If you want a local git hook too:
mkdir -p .githooks
cat > .githooks/pre-commit <<'EOF'
#!/usr/bin/env bash
./scripts/check-story-security.sh
EOF
chmod +x .githooks/pre-commit
git config core.hooksPath .githooks
Success looks like: commits or CI runs fail immediately when a story omits required security metadata.
Step 6: Require verification commands in the story, not in someone’s head
Add exact checks to the story for authz, redirect, and rate limiting. For HTTP services, use commands like:
curl -I http://app.example.internal/api/customers/123
curl -sS -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer BASIC_USER_TOKEN' https://app.example.internal/api/customers/123
curl -sS -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer SUPPORT_AGENT_TOKEN' https://app.example.internal/api/customers/123
seq 1 65 | xargs -I{} -n1 -P8 curl -s -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer SUPPORT_AGENT_TOKEN' https://app.example.internal/api/customers/123 | sort | uniq -c
Success looks like: the story tells reviewers exactly how to prove the control exists.
View as text
[Story] ---- 1. security criteria ----> [PR] | | 2. lint story fields v [CI] ---- 3. pass/fail ----> [Merge] | | 4. run verification cmds v [Service]
Verify it works
Run the repository check:
./scripts/check-story-security.sh
Expected success:
$ ./scripts/check-story-security.sh
$ echo $?
0
Open one completed story and confirm it contains all of these observable items:
rg -n "data_classification|authorized_roles|https_required|throttle_status|compliance_controls|Verification" stories/ docs/
Expected output shape:
stories/customer-profile.yaml:4: data_classification: Confidential
stories/customer-profile.yaml:7: authorized_roles:
stories/customer-profile.yaml:19: https_required: true
stories/customer-profile.yaml:22: throttle_status: 429
docs/story-example.md:24:## Verification
stories/customer-profile.yaml:27: compliance_controls:
For an HTTP-facing story, verify the acceptance criteria are executable and produce the expected status codes:
curl -I http://app.example.internal/api/customers/123
Expected success shape:
HTTP/1.1 308 Permanent Redirect
Location: https://app.example.internal/api/customers/123
Common pitfalls
Writing "must be secure" instead of a testable requirement
Mistake: the story says "follow security best practices" or "must be compliant". Symptom: code review passes, but nobody can prove authz scope, log content, retention, or rate limiting. Fix: replace the sentence with literal status codes, role names, field allow/deny lists, and retention days.
Treating authentication as authorization
Mistake: the story says "user must be logged in" and stops there.
Symptom: any authenticated user can hit an endpoint that should be role-restricted; pentest finds IDOR/BOLA issues.
Fix: add authorized_roles or equivalent acceptance criteria with the exact roles allowed and a deny case returning 403.
Forgetting negative verification commands
Mistake: the story only includes the happy-path request with an admin or valid token.
Symptom: reviewers never test anonymous, wrong-role, or over-limit requests; controls exist on paper only.
Fix: add one deny command per control, e.g. wrong role returns 403, over limit returns 429, HTTP returns 301 or 308.
Logging sensitive fields because the story only required "audit logs"
Mistake: the story requires logging but does not list forbidden fields.
Symptom: logs contain tokens, PAN, SSN, or payload dumps; compliance review blocks release.
Fix: add forbidden_fields explicitly and verify with a grep or log-query check during testing.
Mapping controls after implementation
Mistake: control IDs are added during audit prep instead of story refinement. Symptom: engineers reopen shipped work to explain evidence and retrofit missing retention, MFA, or encryption behavior. Fix: put control IDs in the story up front and require them in the schema check so evidence collection starts with development.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI