The prompt fits on one line: “Here’s how we ship code to production. Threat model it.” The next hour tests whether you can find the two threats that matter in a system you’ve never seen.
Amazon’s security engineer interview prep page lists threat modeling among the technical competencies in a loop of five 60-minute interviews, and says each interviewer will typically ask two or three behavioral questions tied to its Leadership Principles. The whole loop is covered in why the security engineer loop rewards judgment over trivia, and the rest of the section in the security engineer interview guides.
A GitHub Actions deploy pipeline for a payments API
Deploy pipelines make good practice systems: they hold the keys to production and run code from people you don’t control. Acme is a fintech with about 200 engineers on the GitHub Team plan. Its payments API lives in a private repo, acme/payments-api. Every engineer can push branches, and a merge to main deploys to production with no further human step. ci.yml runs on every push and pull request: unit tests, then integration tests on a self-hosted runner in the staging VPC, near the staging database. deploy.yml runs on pushes to main: build the container, push it to Amazon ECR, helm upgrade the production EKS cluster. The job trades a GitHub OIDC token for the deploy-prod IAM role through sts:AssumeRoleWithWebIdentity, so no AWS keys are stored. Two long-lived repo secrets remain: NPM_TOKEN, which publishes the internal SDK, and DATADOG_API_KEY. Actions are referenced by tag, like @v4.
The whiteboard version, boundaries as ==== lines:
Engineer laptop
| DF1 git push, open PR
============================== TB1 person -> GitHub identity
v
GitHub repo acme/payments-api (merge to main = deploy)
| DF2 push or PR on ANY ref runs that ref's workflow file
============================== TB2 any ref -> production access
v
GitHub-hosted runner <-- DF3 -- tagged actions, npm packages
| ================ TB3 our job -> others' code
| DF4 OIDC token -> sts:AssumeRoleWithWebIdentity
============================== TB4 GitHub -> AWS account
v
IAM role deploy-prod
| DF5 docker push | DF6 helm upgrade
v v
ECR payments-api --- DF7 pull ---> EKS production cluster
================= TB6 registry, deployer -> prod
PR integration tests run on a persistent self-hosted
runner in the staging VPC.
================= TB5 untrusted ref -> staging
Six trust boundaries, named before any threat
Threats cluster where trust boundaries are crossed, so name the boundaries first. TB1 runs between a person and a GitHub identity: GitHub believes whoever holds a valid session or token.
TB2 runs between any ref and production access. Candidates tend to draw it between feature branches and main, as if branch protection guarded production. It doesn’t. For push and pull request events, GitHub runs the workflow file from the triggering ref, so a branch’s author decides which secrets its workflow reads and whether it requests an OIDC token. Most CI systems work this way: pipeline config lives in the ref, so whoever can push a ref decides what CI runs and with which credentials.
TB3 separates Acme’s job from code it didn’t write. At TB4, the deploy-prod trust policy is the entire boundary between GitHub and AWS. TB5 is the self-hosted runner’s reach into staging. TB6 decides which images the cluster runs and what the deployer may change.
STRIDE threat model of a GitHub Actions to AWS deploy pipeline
STRIDE came out of Microsoft, where Loren Kohnfelder and Praerit Garg wrote it up in 1999, and Microsoft’s Threat Modeling Tool still sorts threats into its six categories: Spoofing, Tampering, Repudiation, Information disclosure, Denial of service and Elevation of privilege.
| ID | Pipeline element or flow (GitHub Actions to AWS EKS) | STRIDE category | Concrete threat | Impact | Likelihood | Mitigation |
|---|---|---|---|---|---|---|
| T1 | TB1, DF1: engineer pushes to GitHub | Spoofing | A phished GitHub session or leaked classic token pushes branches as a real engineer | High: the way into T2 | Medium: 200 engineers, tokens in dotfiles | Org-required secure 2FA, ideally security keys or passkeys; fine-grained expiring tokens |
| T2 | TB2, DF2: any branch runs its own workflow | Elevation of privilege | A branch edits its workflow to read repo secrets and request an OIDC token; review on main never sees it | Critical: with T7, production AWS | Medium: needs only push access | Production credentials reachable only from an Environment limited to main |
| T3 | TB2, DF2: merge to main | Tampering | Unreviewed code reaches main through an admin bypass, a push after approval, or a bot approval | Critical: a merge is a deploy | Medium: bypass lists grow, and stale-approval dismissal is opt-in | Ruleset with approval on the latest push and no bypass; Actions barred from approving PRs |
| T4 | TB5: persistent self-hosted runner | Elevation of privilege | A PR job leaves a modified toolchain for the next job on that machine | High: a foothold in the staging VPC | Medium: persistent runners keep state | Ephemeral runners; a separate runner group for untrusted refs |
| T5 | TB3, DF3: third-party actions | Tampering | A mutable tag is repointed at a commit that dumps secrets into the job log | High: every secret in the job | Low to medium: tj-actions/changed-files, March 2025 (CVE-2025-30066) | Full commit SHA pins, enforced by the allowed-actions policy |
| T6 | TB3, DF3: npm install in the deploy job | Tampering | A compromised package’s install script runs beside the deploy credentials, or the package ships in the image | High: the role, or code in production | Low to medium: Shai-Hulud spread through npm post-install scripts in 2025 | Separate build and deploy jobs; npm ci against a committed lockfile |
| T7 | TB4, DF4: OIDC token traded for deploy-prod | Spoofing | The trust policy matches sub with StringLike repo:acme/payments-api:*, so any branch or PR run can assume the role | Critical: full deploy-prod permissions | Medium: AWS documents this pattern, and its console defaults the branch to * | StringEquals on the exact environment subject |
| T8 | Runner: long-lived repo secrets | Information disclosure | NPM_TOKEN and DATADOG_API_KEY leak through T2, T5 or T6, or print in a form log masking misses | Medium: the npm token can publish a backdoored SDK | Medium: any job on any ref can read them | npm trusted publishing (OIDC) instead of NPM_TOKEN; Environment-scoped secrets |
| T9 | DF5: docker push to ECR | Tampering | A principal with ecr:PutImage overwrites the tag between build and deploy | High: an unreviewed image in production | Low: few principals can push | Tag immutability; deploy by digest |
| T10 | TB6, DF7: cluster pulls images | Tampering | The cluster runs any image, so a stolen kubectl credential runs unsigned code | High: arbitrary code in production | Low: needs cluster credentials first | Sign in CI with cosign or AWS Signer; verify at admission |
| T11 | TB6, DF6: helm upgrade against EKS | Elevation of privilege | deploy-prod is mapped to cluster-admin, so a compromised deploy job controls every namespace | Critical: every team’s workloads | Low: needs T2, T5, T6 or T7 first | Namespace-scoped RBAC; later, pull-based GitOps |
| T12 | DF4, DF6: deploy audit trail | Repudiation | A manual deploy, or a deploy-prod session from outside CI, can’t be tied to a person and commit | Medium: slows every investigation | Medium: nothing records the actor unless someone sets it up | Run ID in role-session-name; logs in an account CI can’t write to |
| T13 | Whole pipeline | Denial of service | Runner exhaustion or a GitHub Actions outage blocks a hotfix mid-incident | Medium: an outage you can’t patch fast | Low, but on your worst day | Concurrency limits; a logged, tested break-glass deploy |
The chain hiding in T2 and T7
GitHub’s OIDC reference gives a pull request run the subject repo:acme/payments-api:pull_request and a branch run repo:acme/payments-api:ref:refs/heads/<branch>. It says: “To control how your cloud provider issues access tokens, you must define at least one condition, so that untrusted repositories can’t request access tokens for your cloud resources.” Acme defined one. AWS’s IAM guide shows Acme’s exact pattern, repo:ORG/REPO:*, as a condition that “grants access to any branch within the repository.” The console defaults the branch field to *, and IAM’s create-time check rejects a sub only when it is “solely a wildcard character.” Acme’s policy passes every check and admits every branch.
GitHub won’t issue the token without id-token: write, but a branch’s workflow can grant itself that permission. One phished engineer, or one bad package in a branch build, reaches production AWS with a YAML edit and one STS call, and nobody reviews anything. Finding that chain is most of what this prompt tests.
Before your interview, run this table against your own team’s pipeline. A T2, T5 or T7 found there is your answer to “what have you caught in the wild?”
Ranked mitigations, and why that order
Rank on three questions, in order. Does the fix break a path from the least trusted actor to production? How likely is that path? What does the fix cost? The first question is why T5 and T6 (high impact, low to medium likelihood) outrank T1 and T4 (high, medium). Code review can’t catch a repointed tag or a poisoned package, while fixes one and two gate T1, and T4 lands in staging.
Fix first: pin the OIDC trust to a production Environment (T7, T2)
Change the deploy-prod trust policy to accept exactly one subject:
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/payments-api:environment:production"
}
}
Match what your repo emits. Per the same OIDC reference, repos created after July 15, 2026, and repos renamed or transferred after that date, use an immutable format with numeric IDs, which here would read repo:acme@<owner-id>/payments-api@<repo-id>:environment:production.
Then create a production Environment with deployment branches limited to main, and move NPM_TOKEN and DATADOG_API_KEY out of repo scope into it. A branch can still declare environment: production, but GitHub won’t run that job off main, and AWS won’t hand the role to any other subject. That breaks the chain for branch-originated T1 and T2, though not T6, which runs in the deploy job on main.
On Team, the main-only rule and the exact sub are the whole gate, and they still break T2 plus T7. GitHub’s environment docs say: “If you are on a GitHub Free, GitHub Pro, or GitHub Team plan, required reviewers are only available for public repositories.” On Enterprise, add a required reviewer and turn on Prevent self-review, so whoever triggers a deployment can’t approve it. That adds a human step Acme didn’t have, and interviewers will push on what it costs in deploy speed and rubber-stamping.
Fix second: make main as hard to change as production (T3)
After fix one, main is the only road to production. Add a ruleset: one approval, approval required on the most recent reviewable push, an empty bypass list. If the org setting “Allow GitHub Actions to create and approve pull requests” is on, a branch workflow can approve its own pull request and satisfy “one approval.” GitHub shipped this control in 2022 to protect “against a user using Actions to satisfy the ‘Required approvals’ branch protection requirement.” Turn it off, make GITHUB_TOKEN read-only by default, and require CODEOWNERS review on .github/workflows/.
Fix third: run code you didn’t write where there’s nothing to steal (T5, T6, T9)
In the tj-actions compromise (CVE-2025-30066), existing version tags were moved to a malicious commit, so repos that changed nothing ran it. GitHub described Shai-Hulud, the npm worm it learned of in September 2025, as “a self-replicating worm” that spread “via compromised maintainer accounts by injecting malicious post-install scripts into popular JavaScript packages.” Split deploy.yml into a build job with no cloud credentials and a deploy job that installs nothing. That stops an install script from stealing the role. It doesn’t stop a malicious package that ships inside the image, and signing later signs whatever was built. So install with npm ci against a committed lockfile, add --ignore-scripts where the build tolerates it, and pin actions to full commit SHAs, enforced by the SHA-pinning option in the allowed-actions policy. Add ECR tag immutability and digest deploys too.
Then shrink the blast radius (T1, T4, T11, T8)
T1 lands here: after fixes one and two a stolen session can push a branch but needs a second approver to reach main, and required 2FA is an org setting, not a pipeline fix. Make the self-hosted runners ephemeral, with untrusted refs on their own runner group. Scope the deploy identity with a namespace-level RBAC role instead of cluster-admin; longer term, pull-based GitOps with Argo CD or Flux keeps cluster credentials out of CI. If the SDK is on npmjs.com, replace NPM_TOKEN with npm trusted publishing, which uses OIDC from GitHub-hosted runners.
Later, or accept with eyes open (T10, T12, T13)
Provenance is now a workflow step. GitHub’s artifact attestations need Enterprise Cloud on private repos, so on Team sign with cosign or AWS Signer and Notation; SLSA describes what to record. Enforcing it at admission is where the cost sits, for a low-likelihood path once T9 is fixed; say so rather than leading with it. For T12, put the run ID in role-session-name. Accept T13 with a tested break-glass path.
If the interviewer says “you get three,” name fixes one to three and give the T2-plus-T7 chain as the reason.
The threat modeling interview format, minute by minute
A 60-minute slot splits like this.
| Minutes into a 60-minute threat modeling interview | What the candidate does | What the interviewer is checking |
|---|---|---|
| 0–5 | Ask scoping questions; state assets and attackers | Defining the problem before solving it |
| 5–15 | Draw components, number the data flows, label the trust boundaries | Finding where trust changes, unprompted |
| 15–35 | Walk boundary crossings with STRIDE as a checklist; 10 to 15 threats | Threats specific to this system, not recited |
| 35–45 | Stop listing; rate impact and likelihood; look for chains; pick a top three | Telling the headline threat from the footnote |
| 45–60 | Defend fixes under pushback; residual risk, detection, your questions | Depth, and updating on new facts |
In 45 minutes, compress scoping, drawing and enumeration, never the ranking.
Scoping questions to ask before you draw
- Is the worst outcome someone else’s code in production, or payment data walking out?
- Who’s the attacker: an outsider with a stolen credential, an insider, a compromised dependency?
- Which GitHub plan, is the repo private, and do forks open pull requests?
- What’s in place today: SSO, branch protection, deploy approvals?
Draw boxes for things that run code, numbered arrows for data flows and a labeled line per boundary. Numbers pay off at minute 40, when “T2 plus T7” replaces re-describing two arrows. Keep talking while you draw.
When to stop enumerating
Stop when every boundary has a threat and you’ve found one path from the least trusted actor to the asset, or at minute 35, whichever comes first. Say it: “I have twelve. I’ll rank these unless you want me deeper somewhere.” Run the six letters once before you stop; people forget repudiation and denial of service.
How interviewers grade a threat model
Amazon’s page names the competency without saying how it’s scored, and few companies publish a rubric, so treat this as a practitioner’s read.
Say assumptions out loud: “I’m trusting GitHub and AWS as platforms” lets the interviewer redirect you in ten seconds. Threats are judged on specificity. “An attacker could compromise CI” gives them nothing to grade, while “a branch workflow reads NPM_TOKEN and publishes a backdoored SDK” names who does what, on a flow that’s on the board. Ranking needs a stated rule, and mitigations are graded as configuration: which setting, owned by whom. “Add MFA” scores close to nothing.
Follow-up questions interviewers ask
“Make the repo public. What changes?” Fork pull requests don’t get repo secrets, so T2 narrows, and required reviewers become available on every plan. You’ll want one. Since December 8, 2025, pull_request_target takes its workflow file and checkout from the default branch, and Environment rules evaluate against it. So if main already has a pull_request_target job that checks out fork code and declares environment: production, the main-only rule passes, the sub matches, and only a required reviewer stops a stranger’s code from running with the production role.
“How would you know T7 had already been used?” Match each CloudTrail AssumeRoleWithWebIdentity event for deploy-prod to an expected workflow run on main. A session without one is your incident.
Failure modes that sink candidates
The most common is listing attacks before drawing anything, which produces the OWASP Top 10 and nothing about this system. Next is filling in the STRIDE grid: ten elements times six letters is sixty cells, and the round ends before you rank.
Some candidates defend the system. When the interviewer plays the owner and says “we already have branch protection,” ask what it protects; that’s how you find T2. Others hand-wave the platform: if you name GitHub Actions, know where workflow files come from and what your plan includes. The GitHub Actions system design walkthrough covers the platform.
Other threat modeling interview questions, and where the chain hides
The method carries to other common prompts; for the app-layer ones, see web security for system design interviews.
- Login and password reset: the reset token, and whether reset quietly skips MFA (the user authentication design has the moving parts).
- S3 presigned upload: who picks the object key and content type, and whether uploads are served from your origin.
- Mobile app plus API: any check the app enforces that the API doesn’t repeat, since the attacker owns the client.
- Internal admin tool: SSO at the front door and no per-action authorization behind it.
- An LLM agent with a repo-write token and a browsing tool: a fetched page injects instructions, and the agent pushes code with its token.
In each, the chain runs through the component that acts on input it didn’t choose. Circle that box first.
Practice the behavioral round:
