At 2 a.m., when no one is watching, a scheduled job wakes up, calls the API, finishes its work, and goes back to sleep. That stretch of "running unattended" is the part I find myself worrying about most. As an indie developer, I keep several sites updating themselves on staggered schedules, and honestly, the flashy new features matter less to me than one quiet question: is the API key I left sitting there going to leak someday without my noticing?
In its June 28, 2026 update, Claude Code signaled a move from static API keys toward short-lived, scoped credentials issued through WIF (Workload Identity Federation). It works with an OIDC-compliant identity provider, handing out throwaway credentials at request time. This article translates that idea down to a small, one-person, unattended pipeline: what you move, and how the blast radius shrinks when you do.
The scary part of a static key isn't the leak — it's the window
Putting ANTHROPIC_API_KEY in an environment variable or a .env file and reading it from cron works fine, and it's enough to start with. The problem is that the key stays valid forever.
The real danger of a static key isn't the moment it leaks. It's the window between the leak and the day you find out. It got printed into a CI log by accident, it rode along in a backup, it lingered in the shell history of an old machine — and in every one of those cases, you usually discover it weeks later. For all that time, a fully scoped, valid key has been walking around outside.
Unattended operation sharpens this. No one is staring at a dashboard, so a sudden spike of suspicious requests goes unnoticed until well after the fact. That's exactly why two properties — "short-lived even if leaked" and "narrowly scoped even if leaked" — change how safe the whole setup feels.
WIF doesn't hand out keys — it trades for them on the spot
The idea behind WIF, in one line: stop distributing a long-lived secret (the static key), and instead trade a trustworthy proof of identity for a short-lived credential, every time you need one.
The flow is roughly three steps. First, the runtime obtains a token proving its identity (an ID token) from an OIDC-compliant identity provider. Second, it presents that token to a credential issuer and receives a short-lived, narrowly scoped credential in return. Third, it calls the actual API with that short-lived credential. Once the job ends and the credential expires, it's worthless to anyone.
Lining up static keys against short-lived credentials makes the operational meaning clear.
| Aspect | Static API key | WIF short-lived credential |
|---|---|---|
| Lifetime | Valid until you revoke it | Auto-expires in minutes to ~1 hour |
| Storage | Env var, .env, secret store | In memory only; never persisted |
| Impact if leaked | Full-scope abuse until noticed | Useless after expiry; scope-bound |
| Rotation | Manual or a periodic job | Effectively rotated every request |
| Proof of identity | Possessing the key = you | OIDC token verifies the runtime |
The bottom-right cell is the point. With a static key, possession is identity, so a copy is game over. WIF verifies which runtime the request came from every time, so lifting the key string alone gets an attacker nowhere without an environment that passes verification.
The smallest migration unit: turn "inline key" into "fetch function"
You don't have to move everything to WIF at once. The smallest possible step is to replace every spot that reads the API key directly with a call to a function that fetches a credential. Hide that behind a function, and you can swap the insides from a static key to a short-lived credential later without touching any caller.
The code below packs that fetch function into a single class. It trades an OIDC token for a short-lived credential, caches it in memory until expiry approaches, and refreshes proactively just before the deadline. Endpoints and field names vary by environment and provider, so fill them in while checking the real field names in the official docs.
import os
import time
import threading
import requests
class ShortLivedCredentialProvider:
"""Trade an OIDC token for a short-lived credential; refresh before expiry.
Callers only call get(). Whether the value is a static key or a WIF
credential, the interface stays the same.
"""
# How many seconds before expiry to stop using it (clock skew + latency margin)
REFRESH_BUFFER_SEC = 120
def __init__(self, token_url: str, exchange_url: str, audience: str):
self._token_url = token_url # where to get the OIDC ID token
self._exchange_url = exchange_url # where to exchange for a short-lived credential
self._audience = audience # audience the exchanger expects
self._lock = threading.Lock()
self._cached_value = None
self._expires_at = 0.0
def _fetch_id_token(self) -> str:
# Obtain an OIDC ID token from the runtime's metadata service, etc.
# The retrieval method differs per cloud/CI, so adapt this part.
resp = requests.get(
self._token_url,
params={"audience": self._audience},
headers={"Metadata-Flavor": "Google"}, # example; varies by provider
timeout=10,
)
resp.raise_for_status()
return resp.text.strip()
def _exchange(self, id_token: str) -> tuple[str, float]:
# Present the ID token and receive a short-lived, scoped credential.
resp = requests.post(
self._exchange_url,
json={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": id_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"scope": "messages:write", # request only the minimum scope
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
# Map field names to your provider's spec
value = data["access_token"]
ttl = float(data.get("expires_in", 900))
return value, time.time() + ttl
def get(self) -> str:
now = time.time()
# If we're past the pre-expiry mark, take the lock and refresh
if now >= self._expires_at - self.REFRESH_BUFFER_SEC:
with self._lock:
# Another thread may have refreshed while we waited for the lock
if now >= self._expires_at - self.REFRESH_BUFFER_SEC:
id_token = self._fetch_id_token()
self._cached_value, self._expires_at = self._exchange(id_token)
return self._cached_value
# Callers do only this. They never need to know where the key comes from.
provider = ShortLivedCredentialProvider(
token_url=os.environ["OIDC_TOKEN_URL"],
exchange_url=os.environ["CREDENTIAL_EXCHANGE_URL"],
audience=os.environ["EXCHANGE_AUDIENCE"],
)
# Hand the Anthropic client a freshly minted credential each time
from anthropic import Anthropic
def make_client() -> Anthropic:
return Anthropic(api_key=provider.get())The quiet hero here is REFRESH_BUFFER_SEC. Instead of using a credential right up to its expiry, you stop using it a little before. In a long batch, there's a gap between fetching a credential and the call actually reaching the server, plus clock drift between the two machines. Pushing to the exact second occasionally produces "valid by my clock, expired by theirs." Cutting off early is a structural guard against that boundary failure.
The three usual stumbles: clock, scope, fallback
When you actually move things over, the trip-ups are fairly predictable.
First, clock drift. Because short-lived credentials live for so little time, even a small time difference with the server surfaces. On top of the buffer above, confirm once that NTP sync is working on the runtime, and you'll quietly avoid a class of mysterious 401s.
Second, over-broad scope. If you go to the trouble of making credentials short-lived but still mint full-scope ones every time, you've thrown away half the point. Put only the operations the job truly needs into scope. A job that only posts an article shouldn't carry billing or admin permissions. Short-lived and narrow are two different defenses; they only really pay off together.
Third, the direction of your fallback. If you leave an escape hatch that says "on failure, just run with the old static key," you've kept a long-lived, full-scope key alive after all. Here, don't overthink it — fail closed, deny by default. For unattended operation, that's safer than staying up. Prioritize not widening the damage over staying alive. The idea of shrinking the secret-exposure surface itself also comes up in The sandbox can run code but can't read your auth files.
Start with one job, hidden behind the fetch function
Rather than aiming for a full cutover, pick the single job that would hurt least if it broke, and hide its credential behind the fetch function there. Once you confirm the calling code didn't change, you can fold the rest of your jobs in one by one, just by sharing the same function.
And if you've ever committed a key to history in the past, doing an inventory alongside the migration — see Emergency steps when you've accidentally committed an API key — lets you revoke the old static key with confidence. Moving to short-lived credentials makes that "throw the old key away" decision much lighter.
If you're running something unattended too, I hope this helps. Thanks for reading.