{"affected":[{"ranges":[{"events":[{"introduced":"650faa03af511e74c1b3b49ec12d35666b6984c4"},{"fixed":"db58cf3a86c7639708ef52ff86de0b4f938e6360"}],"repo":"https://github.com/hexpm/hexpm","type":"GIT"}]}],"aliases":["GHSA-vmw7-7g2w-gw2f","CVE-2026-86698"],"credits":[{"name":"Peter Ullrich","type":"FINDER"},{"name":"Jonatan Männchen / EEF","type":"ANALYST"},{"name":"Eric Meadows-Jönsson","type":"REMEDIATION_DEVELOPER"}],"database_specific":{"capec_ids":["CAPEC-60"],"cpe_ids":["cpe:2.3:a:hexpm:hexpm:*:*:*:*:*:*:*:*"],"cwe_ids":["CWE-613"]},"details":"## Summary\n\nInsufficient Session Expiration vulnerability in OAuth token issuance in hexpm hexpm allows a user whose organization membership or session has ended to keep reading the organization's private packages and their documentation tarballs via a retained refresh token.\n\n`generate_refresh_token/4` in `lib/hexpm/oauth/jwt.ex` signs the refresh token with the same `iss`, `aud` and `scope` claims as the access token, so it carries the same `repository:<org>` scopes. The CDN service that serves private repositories verifies the signature and time claims and then authorizes from the `scope` claim, with no database lookup and no way to tell the two token kinds apart. Removing a member or revoking a session therefore takes effect at the CDN only when the 30 day refresh token expires, instead of after the 30 minute access token lifetime. Access is read-only and limited to organizations the account belonged to when the token was granted.\n\nThis issue affects hex.pm: from 2025-10-10 before 2026-09-22.\n\n## Details\n\n**1. Token issuance**\n\n`Hexpm.OAuth.JWT.generate_refresh_token/4` builds its claim set from the same template as `generate_access_token/4`, changing only `exp`, so a refresh token carries the `scope` claim of the access token it is issued with. hexpm itself never reads that claim: the refresh grant resolves scopes from the database, and `Hexpm.OAuth.Tokens.lookup/3` matches an access token on the `jti` column and a refresh token on `refresh_jti`, so hexpm's API rejects a refresh token presented as a bearer credential.\n\n**2. Edge authorization**\n\nThe CDN service in front of private repositories consults no database. It verifies the ES256 signature, `iss`, `aud`, `nbf` and `exp`, then grants access when `scope` contains `repository:<org>`. A refresh token sent as `Authorization: Bearer` passes every check and is authorized like an access token.\n\n**3. Effect**\n\nMember removal, session revocation and an organization single sign-on session lapsing rely on the 30 minute access token lifetime to take effect at the CDN, because the CDN has no revocation list. A copy of the refresh token extends that window to its 30 day lifetime.\n\n## Proof of concept\n\n1. Authenticate a CLI session with `mix hex.user auth` for an account that is a member of an organization with private packages. `~/.hex/hex.config` then holds an access token and a refresh token.\n2. Copy the refresh token out of that file.\n3. Remove the account from the organization, or revoke the session.\n4. Request `https://repo.hex.pm/repos/<org>/tarballs/<package>-<version>.tar` with the header `Authorization: Bearer <refresh token>`.\n5. The CDN serves the tarball until the refresh token's 30 day expiry, while the access token from the same file stops working within 30 minutes.\n\n## Impact\n\nA user removed from an organization, or anyone holding a copy of a revoked session's refresh token, can keep downloading that organization's private packages and their documentation tarballs for up to 30 days after access was withdrawn.","id":"EEF-CVE-2026-86698","modified":"2026-09-22T15:51:58.425148Z","published":"2026-09-22T15:51:57.998Z","references":[{"type":"ADVISORY","url":"https://github.com/hexpm/hexpm/security/advisories/GHSA-vmw7-7g2w-gw2f"},{"type":"WEB","url":"https://cna.erlef.org/cves/CVE-2026-86698.html"},{"type":"WEB","url":"https://github.com/hexpm/hexpm/commit/650faa03af511e74c1b3b49ec12d35666b6984c4"},{"type":"FIX","url":"https://github.com/hexpm/hexpm/commit/db58cf3a86c7639708ef52ff86de0b4f938e6360"}],"related":[],"schema_version":"1.7.3","severity":[{"score":"CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N","type":"CVSS_V4"}],"summary":"Refresh tokens accepted as private repository credentials at the CDN","upstream":[]}