LicenseHound

← All articles · 6 min read · 2026-05

Dual-licensed packages and why your scanner gets them wrong

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.

What dual licensing means

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:

How dual licensing is recorded

SPDX uses license expressions to capture multi-license status. The two operators that matter for dual licensing:

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".

Why scanners get it wrong

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.

Why this matters for procurement

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:

  1. The package is 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").
  2. The package is 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.

The correct handling

For each dual-licensed package:

  1. Preserve the full license expression in your SBOM (MIT OR Apache-2.0, not just MIT).
  2. If you're invoking a specific election — picking one license over the other for a specific reason — record that election in a note alongside the SBOM entry.
  3. If you don't know which license you've elected, default to the more restrictive one's obligations. That's the safe behaviour.

What LicenseHound does

For dual-licensed packages, LicenseHound:

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.

Notify me when v0.1 ships: