In May 2026, the Hex.pm package registry received a security audit report from Paraxial. Two CVEs. Both are scope failures. A key labeled "read-only" that wrote packages. A project-scoped token that read data from organizations where the holder had no membership. No exploit was required, just API calls the documentation said were out of scope.
API key scopes are claims. The difference between "read-only" in documentation and "read-only" enforced on every request is exactly where a stolen credential shifts from nuisance to full account takeover.
The Difference Between a Scope Claim and a Scope Constraint
OAuth 2.0 RFC 6749 defines scope as a string the authorization server "may use" to limit access. "May," not "must enforce on every downstream call." Token issuance and token consumption are decoupled. A token server mints a JWT with scope claims. Each API endpoint handler must independently validate those claims against the requested operation.
The developer portal documents intent. The middleware on each endpoint enforces, or fails to enforce, that intent. CVE-2026-21621 and CVE-2026-75542 both live in the space between documentation and actual enforcement.
The technical distinction is concrete. The key may exist with a resource='read' field in the platform's database. If a write endpoint's handler does not consult that field before processing the request, the field has no operational effect. Documentation describing the restriction does not substitute for code that verifies it.
CVE-2026-21621: Token Exchange Converts Read-Only to Full Write
CVE-2026-21621 is the canonical instance of the failure. An API key with domain='api' and resource='read' was exchanged via OAuth client_credentials. The resulting JWT received scope='api' instead of scope='api:read'.
The attack path is direct. With the victim's read-only key and a valid TOTP code, the attacker executes the exchange and receives a broad-access JWT. With that JWT, creates a new full-access key with no expiry. The victim's Hex.pm namespace is open for publishing, retiring, or modifying packages indefinitely.
The root cause was a single code path in the token exchange endpoint. The handler read the key's domain field but silently discarded the resource qualifier when building outbound JWT claims. One missing assertion in one endpoint, full write access to the victim's namespace.
CVE-2026-75542, found in the same Paraxial audit, closes the pair. A token with the repositories permission could read private packages from organizations where the holder had no membership. The organization name was not validated against the token's issuing context. Two distinct bugs, same root: scope claims reaching the handler but never checked against the operation.
AmazonS3ReadOnlyAccess and GITHUB_TOKEN: Scope Wider Than Advertised
The AmazonS3ReadOnlyAccess managed policy grants s3:Get*, s3:List*, and s3:Describe* on resource *. The s3:List* wildcard includes s3:ListAllMyBuckets, exposing the full bucket inventory of the account to any holder of this "read-only" policy. No specific bucket restriction exists in the AWS managed policy.
The additional problem with AWS managed policies: AWS itself can update the contents of AmazonS3ReadOnlyAccess without notifying the key holder. An operator who reviewed the policy at key creation has no guarantee that the policy applying today matches the one they approved. AWS inadvertently included s3:GetObject in AWSSupportServiceRolePolicy through that same silent update mechanism.
Before February 2023, GitHub Actions workflows received a GITHUB_TOKEN with write-all permissions by default for all repositories. StepSecurity documented that the majority of workflows with write permissions did not require them. The Shopify case (HackerOne #1087489, CVSS 10.0) found a contributor token with read/write access to Shopify repositories embedded in a public macOS app. The token was not "read-only," and workflow documentation did not make this default visible.
The Zero-Permission Reconnaissance Pattern
sts:GetCallerIdentity requires no permissions to execute. The call returns account ID, ARN, and principal for any valid AWS credential, regardless of the IAM policy attached. TruffleHog uses this call as its default credential validation primitive for AWS keys detected in public repositories.
Before any scoped call, a stolen key exposes three facts to the attacker: key validity, the victim's account ID, and the principal ARN. A key with a zero-permission policy still executes this reconnaissance successfully. Keys with AmazonS3ReadOnlyAccess add the full bucket inventory to the result of that same initial call.
Zero-permission reconnaissance maps the attack surface before any scoped operation is executed. Treating a leaked "read-only" key as low priority without running this test ignores what the key actually exposes at first use by the attacker.
How Scope Bypass Works at the Code Level
Three recurring patterns emerge from scope CVEs across production APIs.
Token exchange that discards qualifiers. The handler reads the top-level field from the token (domain, resource type). The sub-qualifier (read vs write, project vs org) is silently dropped when the downstream token is issued. CVE-2026-21621 is the canonical case: domain='api' was read, resource='read' was silently discarded. The outbound JWT inherited the broad scope, not the restricted one.
Handler reading the wrong field. The endpoint has access to the token context object but reads user.permissions instead of token.scope, or validates against the user's plan rather than the specific key's permissions. The authorization logic exists, but points to the wrong attribute.
Managed policy vs. inline policy. In AWS, managed policies like AmazonS3ReadOnlyAccess can be updated by AWS itself without notification. Stripe demonstrates the correct pattern: restricted keys (rk_live_) have per-resource permissions validated server-side on every call. A key with Refunds:none receives an explicit 403, not a documentation reminder.
Blast Radius Testing Methodology
The procedure for a leaked key with apparently limited scope follows three sequential steps.
Step 1: Validate without touching scoped resources. For AWS: sts:GetCallerIdentity. For GitHub: GET /user. For Stripe: GET /v1/account. Zero-permission calls that confirm liveness and identity without triggering alerts on scoped resources and without consuming quota.
Step 2: Enumerate actual permissions. aws iam simulate-principal-policy lists what a key can do against a specific resource. For other platforms: attempt reads on out-of-scope resources, verify whether write endpoints return an explicit 403 or silently accept the operation.
Step 3: Test scope boundary conditions. Test whether OAuth token exchange expands the scope. Verify whether a project token accesses org-level endpoints. Check whether legacy key types retain permissions that newer scoped keys lost. TruffleHog and GitGuardian classify detected keys by scope. A key classified as "read-only" should be independently verified against write endpoints before receiving low-severity treatment.
The sequence inverts the logic of many incident response playbooks, which classify severity by documented scope before testing actual scope. CVE-2026-21621 was possible precisely because documented scope and enforced scope diverged.
Detecting Scope Failures with MAGO Intel
The MAGO Intel tool (intel.mago.team) includes exposed API key detection as part of the reconnaissance module. For each key found, the pipeline runs liveness validation via zero-permission calls before assigning severity to the finding.
The severity of an exposed key is not determined by scope documentation. It is determined by what the key accepts when tested against operations outside its declared scope. A key labeled "read-only" that passes a write test receives maximum severity regardless of the scanner's initial classification.
The correct detection pattern: find the key, validate liveness, test scope boundaries, report actual blast radius. Classifying before testing reproduces exactly the assumption CVE-2026-21621 exploited in the Hex.pm systems.
The next "read-only" API key finding in a dependency audit is not low priority until the test says so. Scope is a claim. Verification is a test. Run the test.
Top comments (0)