CVE-2026-86698 Overview
CVE-2026-86698 is an Insufficient Session Expiration vulnerability [CWE-613] in the hexpm package repository. The flaw resides in OAuth token issuance, where refresh tokens carry the same scope claims as access tokens. As a result, users removed from an organization or whose session was revoked can continue reading private packages and documentation tarballs from the Content Delivery Network (CDN) until the 30-day refresh token expires, rather than the intended 30-minute access token lifetime. Access is read-only and limited to organizations the account belonged to when the token was granted. The issue affects hex.pm from 2025-10-10 until it was fixed on 2026-09-22.
Critical Impact
Revoked users or ex-organization members can retain read access to private hexpm packages and documentation for up to 30 days after removal.
Affected Products
- hexpm hex.pm service
- Versions deployed between 2025-10-10 and 2026-09-22
- Private organization repositories served through the hexpm CDN
Discovery Timeline
- 2026-09-22 - CVE-2026-86698 published to the National Vulnerability Database (NVD)
- 2026-09-22 - Patches merged in commits 650faa0 and db58cf3
- 2026-09-22 - Last updated in NVD database
Technical Details for CVE-2026-86698
Vulnerability Analysis
The hexpm OAuth implementation issues both access tokens and refresh tokens using the same JSON Web Token (JWT) signing routine. The generate_refresh_token/4 function in lib/hexpm/oauth/jwt.ex signs refresh tokens with identical iss, aud, and scope claims as the access token. Both tokens therefore carry the same repository:<org> scopes.
The CDN service that fronts private repositories only verifies the token signature and time-based claims. It authorizes requests directly from the scope claim without any database lookup. Because the CDN cannot distinguish access tokens from refresh tokens, either token type is accepted for repository reads.
Removing an organization member or revoking a session updates server-side state but does not invalidate already-issued refresh tokens at the CDN edge. The revocation therefore only takes effect after the 30-day refresh token lifetime, rather than the intended 30-minute access token window.
Root Cause
The root cause is a missing token type discriminator combined with stateless CDN authorization. Refresh tokens were minted with repository scopes intended solely for access tokens, and the CDN had no mechanism to enforce the shorter access-token expiry against a longer-lived refresh token bearing the same claims.
Attack Vector
An attacker who is a former organization member, or whose session was administratively revoked, presents their retained refresh token to the CDN endpoint serving private repositories. The CDN validates the signature and expiry, reads the repository:<org> scope, and returns the private package contents or documentation tarball. No credential replay against the identity provider is required.
// Patch: distinguish token types via RFC 9068 media type
// Source: https://github.com/hexpm/hexpm/commit/db58cf3a86c7639708ef52ff86de0b4f938e6360
@doc """
Generates a JWT access token for a subject (user or organization) with the given scopes.
Signed with the `at+jwt` media type registered by RFC 9068, which is how the
CDN edge tells an access token from a refresh token.
"""
def generate_access_token(subject_name, subject_type, scopes, opts \\ []) do
jti = generate_jti()
The fix adopts the at+jwt media type from RFC 9068 so that the CDN edge can reject refresh tokens presented as bearer credentials for repository reads.
Detection Methods for CVE-2026-86698
Indicators of Compromise
- CDN access logs showing repository tarball or documentation downloads from user accounts whose organization membership was recently revoked.
- Bearer token presentations against private repository endpoints with a jti matching a token issued more than 30 minutes prior to the request.
- Requests using JWTs lacking the at+jwt media type after the patch is deployed.
Detection Strategies
- Correlate organization membership change events with subsequent CDN reads scoped to repository:<org> for the removed principal.
- Decode presented JWTs at the edge and alert when the typ header is absent or does not equal at+jwt for repository read paths.
- Track the delta between session-revocation timestamps and the last observed successful package fetch per principal.
Monitoring Recommendations
- Ingest hexpm application logs and CDN access logs into a centralized analytics platform for cross-source correlation.
- Alert on any repository:<org> scoped request originating from a sub no longer present in the organization membership table.
- Baseline normal per-user download volumes and flag spikes that occur immediately following a membership removal.
How to Mitigate CVE-2026-86698
Immediate Actions Required
- Upgrade self-hosted hexpm deployments to a build that includes commits 650faa0 and db58cf3.
- Rotate or invalidate all outstanding refresh tokens for users who have been removed from private organizations since 2025-10-10.
- Audit private package download logs for the affected window to identify unauthorized reads by former members.
Patch Information
The fix is delivered across two commits. Commit db58cf3 introduces the RFC 9068 at+jwt media type so the CDN can distinguish access tokens from refresh tokens. Commit 650faa0 adjusts token lifetimes and refresh token handling in lib/hexpm/oauth/tokens.ex and related factories. See the GitHub Security Advisory GHSA-vmw7-7g2w-gw2f and the CNA advisory for full details.
# Patch: enforce refresh token expiry and separate token lifetimes
# Source: https://github.com/hexpm/hexpm/commit/650faa03af511e74c1b3b49ec12d35666b6984c4
def oauth_token_factory() do
expires_at = DateTime.add(DateTime.utc_now(), 30 * 24 * 3600, :second)
refresh_token_expires_at = DateTime.add(DateTime.utc_now(), 30 * 24 * 3600, :second)
%Hexpm.OAuth.Token{
jti: "test-jti-#{:erlang.unique_integer()}",
token_type: "bearer",
scopes: ["api"],
expires_at: expires_at,
refresh_token_expires_at: refresh_token_expires_at,
grant_type: "authorization_code",
client_id: Hexpm.OAuth.Clients.generate_client_id()
}
end
Workarounds
- Rotate organization-level API keys and repository credentials after removing any member, forcing reissuance of all tokens for that organization.
- Shorten refresh token lifetimes in configuration where possible to reduce the exposure window below the default 30 days.
- Route CDN requests through an authorization proxy that performs a live database membership check before serving repository tarballs.
# Verify deployed hexpm includes the security fixes
git -C /opt/hexpm log --oneline | grep -E "650faa0|db58cf3"
Disclaimer: This content was generated using AI. While we strive for accuracy, please verify critical information with official sources.