A dual-licensed package — sometimes called multi-licensed — gives the user a choice. You can use it under license A, or under license B, at your discretion. Each choice has different obligations. Most automated license scanners just pick one. That's a footgun.
Some upstream projects publish code under more than one license simultaneously. The most common pattern is a permissive license OR a copyleft one — for example, MIT OR GPL-2.0. The user picks the one they want to comply with; the upstream doesn't care which.
This pattern shows up most commonly in:
MIT OR Apache-2.0 declaration, allowing GPL-compat use cases either way.MIT OR Apache-2.0 specifically so users who want Apache's explicit patent grant can opt in.SPDX uses license expressions to capture multi-license status. The two operators that matter for dual licensing:
OR — the user chooses one. Example: MIT OR Apache-2.0.AND — both apply, and the user must comply with both. Rarer, but it exists. Example: GPL-2.0-only AND MIT.In package.json, you'll see this as "license": "MIT OR Apache-2.0" or sometimes the older "licenses" array form: "licenses": [{"type": "MIT"}, {"type": "Apache-2.0"}]. The latter is ambiguous about whether it's OR or AND; the SPDX spec says treat it as AND, but many maintainers used it expecting OR semantics.
In uv.lock and the equivalent for poetry, you'll see license-expression = "MIT OR Apache-2.0".
The simplest path through the code looks something like:
# Pseudo-code from a typical license scanner
license_str = package.get("license", "")
if license_str:
print(f"{name}: {license_str}")
else:
print(f"{name}: UNKNOWN")
A package with license = "MIT OR Apache-2.0" gets printed as the literal string MIT OR Apache-2.0. That's actually... fine. The wrong implementation is when the scanner tries to "normalise" it:
# The wrong implementation
license_str = package.get("license", "")
# Try to extract a single SPDX identifier
match = re.search(r"(MIT|Apache-2.0|GPL-[\d.]+)", license_str)
if match:
print(f"{name}: {match.group(1)}")
This regex picks the first match. MIT OR Apache-2.0 becomes MIT. The Apache-2.0 option vanishes. Now the SBOM you generate misses that the package has a patent grant under one of its options.
Worse: MIT OR GPL-2.0 becomes MIT. The fact that the user has an explicit GPL option — which a procurement reviewer would want to know about because it's evidence of upstream's intent — is silently dropped.
When a customer's legal team reviews your SBOM, they'll check each package's license against their internal policy. For a dual-licensed package, your statement of which license you've picked is part of the answer.
Two common cases:
MIT OR Apache-2.0. You almost certainly want the Apache-2.0 option, because of its explicit patent grant. Your SBOM should declare Apache-2.0 with a note ("dual-licensed under MIT OR Apache-2.0; we elect Apache-2.0").AGPL-3.0 OR Commercial. If you've bought the commercial license, declare that. If you haven't, you're using it under AGPL — declare AGPL and accept the source-disclosure obligation. More on AGPL.If your SBOM lists MIT for a package that's actually MIT OR Apache-2.0, you've answered the customer's question incorrectly. They won't notice. You won't notice. But during a future audit — say, due diligence around an acquisition — the discrepancy comes up. Now you're explaining why the SBOM you sent two years ago doesn't match the package's actual license declaration.
For each dual-licensed package:
MIT OR Apache-2.0, not just MIT).For dual-licensed packages, LicenseHound:
licensehound.policy.toml so subsequent scans don't re-flag it.The "flag and surface" pattern is the design principle for the whole tool: when there's ambiguity, we make the user resolve it once, then we remember the resolution. Compared to scanners that pick a license confidently and quietly, the LicenseHound output is sometimes less tidy on first run — but it's correct, and the nth run is fast and identical to the previous one.