Securing a monorepo means scoping every control — reviews, scans, secrets, and CI permissions — to the paths a change actually touches, instead of running everything against everyone on every commit. Get that one principle right and a 400-developer monorepo can have stricter security than a fleet of microrepos. Get it wrong and you end up where most teams do: a 45-minute "security" stage that everyone rubber-stamps retries on, and a growing Slack channel dedicated to asking for exceptions.
I have spent the last three years on the OSPO side of a monorepo with roughly 900 workspaces. Here is what actually held up.
Make ownership machine-readable first
Everything downstream depends on knowing who owns what. A CODEOWNERS file is the cheapest security control you will ever deploy, and in a monorepo it is load-bearing:
# .github/CODEOWNERS — later rules win
* @org/platform-review
/packages/payments/** @org/payments @org/security
/packages/auth/** @org/identity @org/security
/infra/terraform/** @org/sre
/.github/workflows/** @org/security
Two non-obvious rules. Put /.github/workflows/** under security ownership — workflow files are executable supply chain, and the Codecov and tj-actions incidents both traveled through CI config. And resist per-file granularity; if a directory needs three owner entries, it probably needs to be two directories.
Enable required review from code owners in branch protection, then check the config drift quarterly. We found 14 percent of our protected paths had silently lost enforcement after a repository settings migration. Nobody noticed for five months.
Path-filter the expensive checks
The core performance trick: security checks should be triggered by what changed, not by the fact that something changed. Every major CI system supports this natively.
GitHub Actions:
on:
pull_request:
paths:
- "packages/payments/**"
- "pnpm-lock.yaml"
For task-graph tools, use the affected computation instead of path globs. With Nx, nx affected -t lint,test,scan runs targets only for projects reachable from the changed files through the dependency graph. Turborepo's equivalent is turbo run scan --filter=...[origin/main]. The graph-based approach beats path globs because it catches the case globs miss: a change to a shared internal library that 60 workspaces consume.
One check must never be path-filtered: lockfile scanning. Any PR that touches pnpm-lock.yaml, Cargo.lock, go.sum, or uv.lock gets the full dependency review regardless of which workspace triggered it. Lockfiles are the single choke point where a malicious package enters, and they are exactly what attackers modify. GitHub's dependency-review-action does this well and fails fast — typically under 30 seconds — because it diffs manifests rather than rescanning the world.
Scan per-workspace, report per-workspace
A monorepo-wide vulnerability report is noise. "The repo has 3,100 findings" tells no team what to fix. The scanner needs to attribute every finding to the workspace whose manifest pulled it in, so the payments team sees their 12 findings and nobody else's.
Practically, that means running your SCA tool with workspace awareness. Modern SCA tooling resolves pnpm and Cargo workspaces into separate projects from a single scan; if yours does not, run one scan per manifest directory and tag results:
for dir in $(git diff --name-only origin/main | xargs -n1 dirname | sort -u); do
test -f "$dir/package.json" && osv-scanner scan --lockfile "$dir/pnpm-lock.yaml"
done
Crude, but the principle survives translation to any tool: findings without an owner are findings without a fix. Pair this with reachability analysis where you can get it — in a monorepo the same vulnerable transitive dependency is often reachable from two workspaces and dead code in forty, and treating those identically burns team goodwill you will need later.
Contain the blast radius of CI credentials
Monorepo CI tends to accumulate god-mode credentials because one pipeline serves everyone. Unwind that deliberately:
- One OIDC-federated cloud role per deployable workspace, not one for the repo. A compromised test in
packages/docsshould not be able to assume the payments deploy role. permissions: read-allas the workflow default, elevated per-job only.- Pin third-party actions to full commit SHAs. After the tj-actions/changed-files compromise in March 2025 this stopped being a nice-to-have.
- Separate the runner pools. Untrusted PR builds from forks run on ephemeral runners with no network egress to internal registries.
The pattern to internalize from incidents like Codecov's bash uploader compromise is that CI is not adjacent to the supply chain — it is the supply chain.
Use a merge queue so security checks can afford to be strict
The political problem with strict checks is that they make merging miserable at scale: by the time your PR is green, main has moved and you rebase into another 30-minute wait. A merge queue (GitHub merge queue, Mergify, or bors-style bots) fixes the incentive structure. PRs run the cheap path-filtered checks; the queue runs the full battery against the speculative merge result and only then advances main.
This is what lets you keep checks that would otherwise be politically unsurvivable — full workspace-graph tests, container rebuilds, provenance attestation — because they run once per merge batch instead of once per push. Our p50 time-to-merge dropped from 3.1 hours to 41 minutes when we moved the heavy security stage from PR to queue, with zero checks removed.
Decide what stays global
A few controls should stay repo-wide no matter the cost, because scoping them creates gaps:
| Control | Scope | Why global |
|---|---|---|
| Secret scanning + push protection | Every commit | Secrets do not respect workspace boundaries |
| Lockfile / manifest diff review | Every PR touching them | Single entry point for malicious packages |
| Workflow file review | Every PR touching .github/ | CI config is executable |
| Branch protection on main | Repo | One main, one policy |
Everything else — SAST depth, license checks, container scans, DAST — earns its place per-path. If you cannot articulate which team acts on a check's failure, the check is theater.
Frequently asked questions
Is a monorepo more or less secure than many small repos?
Neither, inherently. A monorepo concentrates risk (one repo compromise touches everything) but also concentrates control — one CODEOWNERS file, one branch protection policy, one place to audit. Most organizations enforce policy more consistently in a monorepo because there is exactly one place to configure it.
How do I stop one team's vulnerable dependency from blocking another team's deploy?
Attribute findings to the workspace that declares the dependency and gate each deployable on its own findings only. Graph-aware tools like Nx and Turborepo, or an SCA platform that understands workspaces, make this attribution automatic; monorepo-wide pass/fail gates are the single biggest self-inflicted slowdown.
Should every workspace have its own lockfile?
Prefer one root lockfile per ecosystem (pnpm, Cargo, and Go all support workspace-level lockfiles) because it gives you a single diff surface to review and scan. The exception is deployables with radically different risk profiles, where separate lockfiles let you freeze one product without freezing all of them.
Where does Safeguard fit in a monorepo setup?
Safeguard's scanner resolves workspaces into separately owned projects, so per-team findings and per-deployable gates come out of one scan of the repo. If you are comparing options, our comparison with Snyk covers how the two handle monorepo attribution differently.