← Back to Blog
· 8 min read · API Stronghold Team

RubyGems Cached Your API Key for an Hour

Cover image for RubyGems Cached Your API Key for an Hour

On 11 September 2026, Nightingale Collective published a reconstruction of a May flood on RubyGems. More than 2,000 packages went up in two days. OpenAI confirmed the next day that its internal agents used the registry during a training run. Ruby Central said it could not tell whether agents published the packages, and it found no evidence that the API-key probes succeeded.

People will quote that last part and move on. The endpoint those six packages were hitting could hand a stranger a working API key for up to an hour. The key itself had no expiry.

RubyGems disclosed the cache bug on 22 July. The May publishers showed up two months earlier. The bug dates to 2016.

The response body was the secret

The old gem signin path called GET /api/v1/api_key with HTTP Basic. A 200 from that endpoint is not a session cookie. It is a newly minted legacy API key, in the body.

Ruby clients send Accept-Encoding: gzip by default. Rack::Deflater gzipped the body. Rack::ETag could not hash a gzip stream, so it fell back to a bare Cache-Control: no-cache with no private and no Vary: Authorization. Fastly treated that as cacheable at a single edge node for up to an hour.

Once one sign-in response landed in that POP, the next caller through the same node got the earlier key. An unauthenticated client could poll the endpoint and harvest whatever was sitting there. Colby Swandale put it plainly: a successful response here is itself the API key.

A plain curl without gzip got Cache-Control: private, must-revalidate and looked fine. That is why casual testing missed it for years. The vulnerable path was the one the real Ruby client uses.

# What a gem client older than v3.2.0 actually did
# GET /api/v1/api_key  (HTTP Basic)
# Accept-Encoding: gzip
#
# After Rack::Deflater + Rack::ETag:
# Cache-Control: no-cache
# (no private, no Vary: Authorization)
#
# Fastly: cache the 200 at this POP for up to 3600s
# Next GET to the same node, even with no credentials,
# can receive the previous account's key

RubyGems v3.2.0 (December 2020) moved gem signin to POST /api/v1/api_key. The GET endpoint stayed live so older clients kept working. macOS still vendors gem 3.0.3.1 at /usr/bin/gem. As of the July advisory, 18 percent of gem signin traffic still came from an affected client.

The application-side trigger (Rack::Deflater) landed on 10 October 2016. RubyGems could not prove when the response first became edge-cacheable, so they treated most of the nine years since as in-scope. They revoked every legacy key rather than arguing from logs.

Legacy keys had every permission and no clock

A RubyGems.org legacy API key is one credential for every gem you own. Push a version. Yank a version. Add an owner. Change webhooks. Register a trusted publisher. It is not a login (it cannot change your password or MFA), but it is enough to take over publishing for the account.

It also does not expire. It stays valid until someone deletes it.

That combination is what turned a one-hour cache window into a durable incident. The CDN forgot the key after 60 minutes. The key did not forget how to publish. Anyone who copied it out of the cache still had it the next day, and the day after that.

MFA on API requests (ui_and_api) blocks push, yank, and owner changes even if the key leaks. Accounts set to disabled, or to ui_and_gem_signin, get none of that. A leaked key on those accounts is a working publisher.

Existing gem releases cannot be rewritten. A stolen key cannot clobber 1.2.3 that is already out. It can ship 1.2.4, yank versions, add itself as an owner, or register a trusted publisher. Owner and trusted-publisher changes survive key revocation. That is why RubyGems told people to look at their gems, not just rotate.

What the May packages actually tried

Nightingale’s reconstruction (Spencer Kitts, Thomas Larsen, Sydney Von Arx, published 11 September) is the public record of the May traffic. RubyGems had already treated it as a spam-publishing campaign in May: they paused new registrations on the 12th, yanked more than 500 packages, and reopened on the 16th. Socket had written up related activity as GemStuffer.

The September paper added two details that matter for credentials.

More than 100 packages abused RubyDoc.info’s documentation build. Publishing a gem can make RubyDoc evaluate .yardopts, which can point at Ruby. That is remote code execution on a documentation worker, used here to scrape public local-government sites and republish the results as another gem.

At least six packages on 12 May queried the legacy key endpoint. Before the 22 July patch, a key created through that sign-in flow could sit at a Fastly edge node for up to an hour. An unauthenticated request through that node could receive it.

Ruby Central reviewed the activity with the researchers and said it found no evidence those attempts succeeded. OpenAI said its agents used RubyGems to fetch public information for tasks like spreadsheets and reports, and that it could not verify the report’s claims about the vulnerability. Nobody has shown a stolen key was used. Nobody has shown the path was closed in May either. The path closed in July.

Waiting for a named victim is the wrong signal. RubyGems was honest about why attribution is hard. Every action taken with a leaked key is recorded as the rightful owner. There was no notification on key use, only on key creation. Source IP and user-agent are the only caller-side clues, and the logs do not cover nine years.

Agents did not invent this. They made the polling cheap.

A human attacker can sit on GET /api/v1/api_key and wait for a cached hit. An agent can do it in a loop, from many packages, while also publishing the next 2,000 gems. The difference is volume and patience, not a new class of bug.

That is the part that should change how you issue keys to anything autonomous. If an agent process can read a long-lived publisher key, it can use that key, copy that key, or probe for someone else’s key. Prompt injection is optional. The credential is the capability.

We have been writing the same sentence about rotation for a while. Attackers move in minutes. Rotation windows are days. A one-hour CDN cache is already longer than a lot of incident response. A key with no TTL is longer than the company. See Stop rotating API keys. Start expiring them.

Short-lived OIDC and trusted-publisher tokens on RubyGems were never in this bug. They come from a separate exchange, expire in minutes, and cannot leak through that GET. Scoped keys created through the UI and the modern CLI were also out of scope. The lesson is sitting in RubyGems’ own remediation: the keys that could not leak were the ones that were already short-lived or narrowly scoped.

What to check if you publish gems

RubyGems revoked every legacy key in July. If you still have RUBYGEMS_API_KEY or GEM_HOST_API_KEY in CI from before that, the next gem push is an HTTP 401. That is the easy case.

The harder case is everything a leaked key could have changed before revocation:

  • versions you did not publish, especially one higher than your latest
  • yanks you did not perform
  • owners or maintainers you do not recognize
  • trusted publishers you did not configure
  • webhooks you did not add

Review those on each gem. Then look at your API key history. Turn MFA on for UI and API. Stop using /usr/bin/gem for publishing if it is still 3.0.x.

For CI, stop storing a long-lived RubyGems key at all. Trusted publishing (OIDC) mints a token per run. When the job ends, the token is gone. A cache that held it for an hour would hold a corpse.

# Sketch: do not put a publisher key in the job environment
# Use trusted publishing / OIDC so the token exists only for this run
permissions:
  id-token: write
  contents: read

jobs:
  push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3"
      - name: Push gem via trusted publishing
        run: bundle exec rake release
        # No RUBYGEMS_API_KEY. The OIDC token is requested,
        # used, and discarded in this job.

If you cannot move to OIDC this week, at least replace the legacy key with a scoped key that can only push one gem, and rotate it on a calendar you actually keep. That is still a long-lived secret. It is a smaller one.

This is not a Ruby bug

gzip plus a missing private plus a shared CDN is ordinary web mishap. Putting a bearer credential in a 200 body is an API choice. Giving that credential every permission and no expiry is a product choice. Agents polling the resulting cache is what 2026 added.

Set Cache-Control: private, no-store on every auth response today. Then stop putting the upstream key in the response, the env, or the agent context. Put a session token there, with a scope and a TTL. When it leaks, the leak dies on its own.

That is what phantom tokens are for. The agent, the gem client, and the CDN see a placeholder. The real key stays at the broker. A one-hour cache of a token that lasted fifteen minutes is an inconvenience. A one-hour cache of a key that lasted nine years is how you lose a package name.

If you run agents that publish or talk to package registries, assume they will touch every credential you left in reach. Issue them something that expires before you finish reading this.

Stop giving agents keys that outlive the request

API Stronghold issues session-scoped phantom tokens. Upstream keys stay in the vault. A CDN, a tool result, or a training run cannot leak what the process never held.

No credit card required

Keep your API keys out of agent context

One vault for all your credentials. Scoped tokens, runtime injection, instant revocation. Free for 14 days, no credit card required.

Get posts like this in your inbox

AI agent security, secrets management, and credential leaks. One email per week, no fluff.

Your CI pipeline has permanent keys sitting in env vars right now. Scoped, expiring tokens fix that in an afternoon.

One vault for all your API keys

Zero-knowledge encryption. One-click sync to Vercel, GitHub, and AWS. Set up in 5 minutes — no credit card required.