CLAUDE LABJP
VERSION — v2.1.227 landed on August 10 with no new features, just fixes around plan detection and CI behaviorAUTO — Two days remain until August 14, when auto mode becomes the default in Claude Code for Pro, Max, and TeamCI — Bash commands no longer fail across the board under claude-code-action with allowed_non_write_users on GitHub-hosted runnersBILLING — Sessions started with an expired token could misread your plan and nudge Max users toward usage credits; that is now fixedSUNSET — The legacy Workbench and the experimental prompt tool APIs retire on August 17, five days outPRICE — Sonnet 5 promo pricing at $2/$10 per Mtok runs through August 31, moving to $3/$15 on September 1VERSION — v2.1.227 landed on August 10 with no new features, just fixes around plan detection and CI behaviorAUTO — Two days remain until August 14, when auto mode becomes the default in Claude Code for Pro, Max, and TeamCI — Bash commands no longer fail across the board under claude-code-action with allowed_non_write_users on GitHub-hosted runnersBILLING — Sessions started with an expired token could misread your plan and nudge Max users toward usage credits; that is now fixedSUNSET — The legacy Workbench and the experimental prompt tool APIs retire on August 17, five days outPRICE — Sonnet 5 promo pricing at $2/$10 per Mtok runs through August 31, moving to $3/$15 on September 1
Articles/Claude Code
Claude Code/2026-06-28Intermediate

Stop Leaving a Static API Key in Your Unattended Jobs — Move to Short-Lived WIF Credentials

Claude Code is moving from static API keys toward short-lived, scoped credentials via WIF (Workload Identity Federation). Here is how to translate that idea to a small unattended pipeline so a leaked key has a much smaller blast radius — with working code and the failure modes to watch.

Claude Code216WIFSecurity11API Keys2Automation42

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.

AspectStatic API keyWIF short-lived credential
LifetimeValid until you revoke itAuto-expires in minutes to ~1 hour
StorageEnv var, .env, secret storeIn memory only; never persisted
Impact if leakedFull-scope abuse until noticedUseless after expiry; scope-bound
RotationManual or a periodic jobEffectively rotated every request
Proof of identityPossessing the key = youOIDC 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Claude Code2026-07-25
Locking down Claude Code sandbox egress with strictAllowlist
Tightening automation egress with strictAllowlist in Claude Code v2.1.219, plus measured failure timings that tell a policy deny from DNS and real outages.
Claude Code2026-07-17
The String I Approved Wasn't the String I Read — Testing a Relayed Permission Prompt with Deceptive Characters
I pushed bidi overrides and zero-width characters through my own approval relay. NFKC normalization caught 0%. Here is why, and the implementation that catches 100% with zero false positives.
Claude Code2026-07-05
Rolling Out Trusted Devices for a Small Team: Enrollment, Preflight, and Rotation
How to introduce Team/Enterprise Trusted Devices for a 2-5 person team: device enrollment, an unattended-run preflight gate, and closing the gaps that appear during device rotation and offboarding.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →