Most CI/CD pipelines treat a non-zero exit code from a security scanner as one thing: "fail the build." The Snyk CLI does not work that way. When you run snyk test, snyk container test, or snyk iac test, the process can exit with 0, 1, 2, or 3, and each number means something operationally different — "vulnerabilities exist," "the scan itself broke," and "there was nothing to scan" are three distinct failure modes that pipelines routinely collapse into one. A pipeline step written as snyk test || exit 1 will block a merge identically whether a critical CVE was found or the Snyk API timed out. That ambiguity has real consequences: teams either add blanket continue-on-error flags that silently swallow real vulnerabilities, or they get paged for infrastructure blips that have nothing to do with security posture. This post walks through how Snyk's exit codes are documented to behave, how --severity-threshold and --fail-on modify that behavior, and what a pipeline needs to do to branch on the result correctly.
What exit codes does the Snyk CLI actually return?
The Snyk CLI documents four exit codes for its test-style commands (snyk test, snyk container test, snyk iac test, snyk code test): 0, 1, 2, and 3. Exit code 0 means the scan ran successfully and found no issues at or above the configured threshold. Exit code 1 means the scan ran successfully and did find issues — this is the "action needed" result, not an error. Exit code 2 means the CLI itself failed to complete the scan, for reasons like an expired auth token, a network timeout talking to Snyk's API, invalid CLI arguments, or an unsupported manifest format that caused a parse error. Exit code 3 means the CLI ran but found no supported project or dependency file to test at all — for example, running snyk test in a directory with no package.json, pom.xml, or equivalent manifest. Only exit code 1 reflects an actual security finding; 2 and 3 are both "the scan didn't happen the way you expected," just for different reasons.
Why does exit code 1 not always mean "block the build"?
Because exit code 1 fires for any qualifying vulnerability, not just the ones a team has decided are release-blocking. Out of the box, snyk test returns exit code 1 if it finds even a single low-severity, unfixable vulnerability three dependency layers deep. Many organizations don't want that behavior in a gating step — they want to fail the pipeline on critical and high findings and simply report on the rest. Snyk's CLI has two flags specifically for narrowing what counts toward exit code 1: --severity-threshold and --fail-on. Without either flag set, "one vulnerability of any severity" is enough to produce exit 1, which is why pipelines that adopt Snyk with default settings often see their first-week failure rate spike and then get tuned down as teams add threshold flags.
How does --severity-threshold change exit code behavior?
It changes the bar for which findings count toward exit code 1 by severity level, not by fixability. Setting --severity-threshold=high tells the CLI to only fail (exit 1) if it finds vulnerabilities rated high or critical; low and medium findings still appear in the output for visibility, but they no longer flip the exit code away from 0. The accepted values are low, medium, high, and critical, and the threshold is inclusive upward — medium means "medium or worse triggers the failure." This is the flag most teams reach for first when a pipeline is failing "correctly" but too often, because it directly maps to the severity taxonomy shown in the Snyk UI and in the JSON output's issues[].severity field.
How does --fail-on change exit code behavior, and how is it different?
--fail-on filters by whether a fix exists, not by severity, and the two flags are meant to be combined rather than used as alternatives. The documented values are all (default — any qualifying vulnerability triggers exit 1), upgradable (only fail if at least one vulnerability can be resolved by a dependency upgrade), and patchable (only fail if at least one vulnerability has a Snyk-supplied patch available). The practical use case is a pipeline that doesn't want to block a release over a vulnerability the team has no immediate way to remediate — for example, a critical CVE in a transitive dependency where no maintainer has shipped a fixed version yet. Running snyk test --severity-threshold=high --fail-on=upgradable narrows exit code 1 down to "high or critical severity, and there's an upgrade path available today," which is a materially different gate than the unfiltered default.
How should a CI/CD pipeline capture and branch on these exit codes?
It needs explicit conditional logic, because most CI runners treat any non-zero exit as a failed step by default. A shell step that does nothing but call snyk test and let the shell's default error handling take over will fail identically on exit 1, 2, or 3, which is the exact ambiguity this post opened with. The documented pattern is to capture the exit code explicitly, for example:
snyk test --severity-threshold=high
code=$?
if [ $code -eq 0 ]; then
echo "No qualifying vulnerabilities"
elif [ $code -eq 1 ]; then
echo "Vulnerabilities found — failing build"
exit 1
elif [ $code -eq 2 ]; then
echo "Snyk CLI error — check auth/network, not a security signal"
exit 1
elif [ $code -eq 3 ]; then
echo "No supported manifest found — check pipeline config"
fi
This lets a team decide, deliberately, whether a code-2 tooling error should also block a merge (many teams say yes, since an unverifiable scan shouldn't count as a pass) versus a code-3 result, which usually indicates a misconfigured pipeline step rather than a security condition, and is worth alerting on separately rather than silently treating as green.
Does snyk monitor follow the same exit code rules as snyk test?
No — snyk monitor is built to snapshot a project's dependency graph into the Snyk platform for ongoing tracking, not to gate a build on vulnerability count, so its exit codes are not tied to findings the way snyk test's are. A successful snyk monitor run exits 0 regardless of how many vulnerabilities exist in the project, because its job is to register the snapshot so Snyk can alert later if a new CVE affecting that dependency graph is disclosed. It returns non-zero when the monitor action itself fails — authentication problems, network failures, or an unsupported project type — which mirrors exit code 2's "the tool didn't do its job" category from snyk test, but without an equivalent to exit code 1. Teams sometimes mistakenly wire snyk monitor into a blocking pipeline gate expecting it to behave like snyk test; the documented intent is that monitor runs after merge (often on the default branch) as a continuous-tracking step, while test runs pre-merge as the gating step.
How Safeguard Helps
Understanding a scanner's exit codes is necessary but not sufficient — it tells you whether one tool's one run passed or failed, not whether your overall software supply chain posture is improving across every repo, pipeline, and scanner your organization runs. Safeguard is built to sit above that layer: it ingests findings and pipeline outcomes from tools like Snyk (and others) across every repository, normalizes severity and fix-availability signals so a "high" in one scanner's output is comparable to a "high" in another's, and gives security teams a single view of where CI/CD gates are actually blocking risk versus where misconfigured thresholds or swallowed exit codes are letting things through unnoticed. Instead of manually auditing whether every pipeline correctly distinguishes a vulnerability finding from a tooling error, Safeguard tracks gate behavior and outcome data centrally, flags pipelines where scan results aren't being enforced as intended, and gives teams the audit trail needed for compliance frameworks like SOC 2 that require evidence security checks are both running and actually gating releases — not just present in the YAML.