8 min read

Why are OAuth refresh token files production credentials?

OAuth refresh token files are production credentials. Learn how headless AI agents should store, scope, rotate, and audit them without murky ownership.

Why are OAuth refresh token files production credentials?

A copied auth.json file is not setup debris. If it contains a refresh token, it is a production credential that can keep creating access tokens after the person who copied it has gone home. Calling the machine headless does not change that. It only removes the browser prompt that would have reminded someone to think about ownership.

I have seen teams protect an API key carefully, then send a refresh-token file to a build host in a chat attachment because the access token inside it expired quickly. That is backwards. The short-lived access token is usually the least interesting part of the file. The renewal path is the part an attacker, an over-permissioned agent, or an unaccountable overnight job can keep using.

A refresh token file is a credential package

An OAuth refresh token file is a credential package because it commonly holds enough state to obtain a new bearer token without a human present. The precise fields vary by client library, but the dangerous combination is familiar: a refresh token, a client identifier, a token endpoint, granted scopes, and sometimes a client secret or a device-specific assertion.

Do not let the file extension make the risk feel smaller. JSON is just an envelope. A file named .cache/session.json, token-store.json, or auth.json deserves the same handling as a private SSH key when it can renew access to a production service.

The OAuth 2.0 Authorization Framework, RFC 6749, describes refresh tokens as credentials used to obtain access tokens. It also says the authorization server may issue a new refresh token and that the client must discard the old one. That last sentence is easy to wave away as protocol detail. In an unattended system, it is an operational requirement: two workers holding what they believe is the current file can now race over a credential's identity.

Separate these three things in your inventory:

  • An access token authorizes a request for a limited period.
  • A refresh token authorizes renewal, often across many access-token lifetimes.
  • An OAuth client identifies the software asking for the renewal.

Teams often collapse the first two and then make the wrong expiry argument. A five-minute access token does not make a copied refresh token safe. It may only mean the copied file gives an intruder fresh tokens in five-minute batches.

A production inventory entry should answer more than "which service uses this?" Record the authorization server, OAuth client identifier, resource server, subject or service account, exact granted scopes, environment, issuance time if available, renewal owner, approved execution path, and revocation method. If you cannot fill those fields in, you do not have a credential ready for autonomous use. You have a file that happened to work in testing.

Headless work makes ownership easy to lose

A headless agent needs a renewal arrangement that names an accountable owner. The usual failure starts with a developer authorizing a local tool using their own account, then copying its cache to a server because the server cannot complete the browser flow. The job works, so the copy becomes permanent.

Now ask questions people skip because they are awkward. Does the token represent the developer, the team, or the job? Who can revoke it without breaking somebody else's work? Which repository or agent instruction tells the job where to find it? Can a departing employee invalidate the identity behind it? Does the provider's consent page show a personal account even though the job behaves like a service?

"The platform account owns it" is not an answer unless that account has a documented administrator, recovery procedure, and limited privileges. A token copied from a personal login is worse than a visible API key in one respect: it often arrives as an opaque cache file, so reviewers cannot see which permissions came along for the ride.

Use a dedicated service identity when the provider supports one. Give that identity the smallest set of resource permissions that lets the job finish. Register a separate OAuth client for each meaningful trust boundary, such as development, staging, and production. Do not use one broad client and one broad consent grant because it is convenient to renew.

There is a distinction worth keeping sharp: OAuth client identity and resource identity are different controls. A client identifier says which software requested a token. The subject and scopes say whose resources the token can touch and what it may do. A team that creates a dedicated client but continues to authorize it as a human administrator has improved attribution a little while leaving the privilege problem intact.

For an agent, write a plain ownership record beside the deployment documentation, not inside the token file:

Credential name: billing-export-prod
OAuth client: agent-billing-prod
Resource identity: svc-billing-export
Scopes: reports.read, exports.write
Renewal owner: platform-oncall
Execution path: production job runner through credential broker
Revocation: authorization server admin console and RFC 7009 endpoint

This record is deliberately boring. That is why it helps at 2am. It lets an operator revoke the right grant without guessing whether the file belonged to a developer's old laptop, a staging test, or the job now paging them.

Store the renewal path outside the agent workspace

The agent process should not read a refresh token file at all. If an agent can read the string, it can print it, write it to a log, embed it in a patch, send it to a remote tool, or leave it in a crash report. Prompt instructions against disclosure do not change the process's ability to exfiltrate data it can read.

Filesystem permissions still matter, but they are a containment layer after the architectural choice. A file mode such as 0600 keeps other local accounts out on a conventional Unix host. It does not prevent the authorized agent process, its plugins, its child processes, its debugger, or a backup job from reading the file. It also does not explain why that host has a production renewal credential in the first place.

Put the refresh token in a secret manager, operating-system credential store, or local broker that the agent cannot query for raw values. The broker should accept a narrow action request, fetch or refresh the credential internally, call the approved resource endpoint, and return the result the agent needs. A request may look like this:

{
  "action": "create_export",
  "target": "billing-api",
  "parameters": {
    "report_date": "2026-07-23"
  }
}

The agent receives a response such as this, not a token:

{
  "status": "accepted",
  "export_id": "exp_4821",
  "report_date": "2026-07-23"
}

This boundary prevents a frequent mistake: mounting a secret directory into every job container and calling it controlled access. The mount makes the secret available to every library, shell command, extension, and accidental diagnostic dump inside that container. A broker can reject unknown targets, attach the right credential itself, and keep renewal state out of agent memory.

On macOS, Sallyport follows this pattern for its supported HTTP and SSH actions: credentials remain in its encrypted vault and the agent receives action results rather than plaintext secrets. That design is useful because it treats the request as the thing to authorize, not the token file as a convenience to hand over.

Do not put refresh-token files in repository directories, CI workspaces, shared network folders, home-directory dotfiles, container images, or generic backup paths. Each location creates a distinct copying mechanism, and each copy adds a future revocation problem. If a legacy tool insists on a path, give it a short-lived, isolated runtime directory owned by a credential helper, then make the helper responsible for creating and removing the file. Treat that as a compatibility exception with an expiry date, not your standard pattern.

Scopes should describe one job, not a department

A headless agent should hold scopes that describe its one job. A token allowed to read every project because the agent may someday need another project will eventually be used in a context nobody expected. Broad scope is popular because authorization screens and provider documentation can be frustrating. The cost lands later, during an incident, when revoking one automation token also interrupts unrelated work.

Start with the final API calls, not the provider's list of available scopes. Write down the verbs and resources the job needs. A job that fetches invoices and uploads a finished export may need read access to invoices and write access to an export location. It does not need user administration, repository deletion, billing changes, or permission-management scopes because one person used those permissions during initial setup.

OAuth scopes alone are not enough. Resource-side permissions can widen the effect of a modest-looking scope. A token with files.write can still damage a large estate if its subject has access to every team folder. Bind the service identity to a limited project, folder, organization unit, or repository where the provider allows it. Then test negative cases: an action against an adjacent production resource should fail for a permission reason, not merely because the agent has not tried it yet.

Build separate grants for separate duties even when the provider accepts a combined list. For example, keep a read-only data collection job apart from a job that publishes results. A leaked publisher credential has a different impact, rotation rhythm, and approval owner than a reader. Combining them saves a token renewal flow while making every investigation harder.

Avoid scope patterns based on a human administrator's session. Administrators often consent to broad permissions because they need them for setup. The running agent does not inherit the administrator's judgment. It inherits their authorization.

A useful review test is this: read the consent record and try to state the job in one sentence. If the description becomes "it can manage several things we might need," the grant is not ready. Narrow the job or split it. The inconvenience of maintaining two credentials is cheaper than discovering that an export agent could change identity settings.

Self-refreshing tokens need a serial owner

See every outside call
Record individual HTTP calls in the Activity journal rather than tracing a copied credential file.

Refresh token rotation creates a state-management problem, and unattended workers must solve it deliberately. Many authorization servers rotate refresh tokens: after a successful refresh, the server returns a replacement and may invalidate the previous token. RFC 9700, OAuth 2.0 Security Best Current Practice, recommends refresh token rotation or sender-constrained refresh tokens for public clients to detect replay. That is a sound security recommendation, but it does not make a shared file safe.

Consider a common failure. Worker A and worker B start from the same mounted auth.json. Worker A refreshes first and receives R2; the provider invalidates R1. Before A writes R2, the process dies or the filesystem write lands on a local layer B cannot see. Worker B sends R1, gets invalid_grant, and retries. An operator sees a failed job, copies an older cache from a backup, and now the incident response has created more credential copies.

Use one of these arrangements:

  1. A single credential broker owns refreshes and stores the replacement transactionally.
  2. One scheduled worker owns a particular grant, with an explicit lock and no parallel replicas sharing token state.
  3. Separate grants exist for separate workers, so each refresh token has one writer.

The first arrangement is usually the cleanest. The second can work for a small, controlled job, but lock expiry and crash recovery need real design. The third costs more consent and lifecycle work, yet it contains failures well.

Do not solve the race by turning off rotation if the provider permits it. That recommendation is attractive because it hides the concurrency bug. It also gives a stolen refresh token more time to operate unnoticed. Fix the ownership model instead.

Your persistence path must update the new token atomically and preserve enough metadata to detect a stale writer. At a minimum, store a version, the last successful refresh time, and a stable credential identifier that is safe to log. The secret store should reject an update that claims to replace version 14 when version 15 already exists. A plain overwrite invites a slow worker to resurrect stale state.

When the authorization server offers sender-constrained tokens, understand what is bound. A proof mechanism can make a copied token less useful without the corresponding client-held key. It does not replace access control around that key, nor does it turn a human consent grant into a proper service identity. Treat it as another barrier, not a reason to distribute cache files.

Rotation is a runbook, not a calendar reminder

A rotation policy is credible only when someone can execute it without improvising. Calendar-based rotation has a place, but ownership changes, suspicious activity, host compromise, repository exposure, and unexpected invalid_grant errors should trigger the same prepared sequence. Do not wait for the scheduled date when you suspect a copied token escaped.

A workable runbook has five actions:

  1. Freeze the affected agent or broker path so it cannot continue refreshing during the change.
  2. Identify the OAuth client, resource identity, scopes, and credential version from the ownership record and logs.
  3. Revoke the refresh token or grant at the authorization server, using the provider's console or its RFC 7009-compatible revocation endpoint where available.
  4. Remove every known runtime copy and invalidate any backup or cache process that can restore it.
  5. Re-enroll the dedicated identity, test the minimum allowed action, and record the replacement version.

RFC 7009 defines token revocation and deliberately allows servers to revoke related tokens and grants as part of handling a request. That means an operator must know the blast radius before pressing revoke. A provider may invalidate the current access token, refresh token family, or the whole grant. The right response is not to avoid revocation. It is to document which jobs share a grant so they do not share one accidentally.

Test rotation with a disposable non-production identity. Confirm that a newly issued replacement works, the old token fails, and a stale worker cannot overwrite the new state. Then practice a revoked-token response. You want the job to fail closed with a recognizable error and a ticketable identity, not quietly fall back to a developer's cached login.

Backups need special handling. Encrypted backups still preserve a refresh token until the backup retention period ends. You may not be able to delete historical backup blocks immediately, but you can revoke the exposed grant immediately. Document the retention location so an investigator knows that recovery media contains an obsolete secret, even after it no longer works.

Audit actions and renewal events separately

Keep SSH keys private
Send SSH commands through Sallyport's helper while SSH keys remain outside the agent process.

Audit logs must show both renewal events and the actions performed with the resulting access tokens. A refresh event tells you that a credential remained active. It does not tell you whether the agent read one report or changed a thousand records. Conversely, an API action log without a credential version leaves you unable to tell which renewal path authorized it.

Record safe metadata for each refresh: timestamp, credential identifier, token version before and after, OAuth client identifier, subject identifier, requested or returned scope if the provider exposes it, execution host or broker identity, and outcome. Record safe metadata for each protected action: agent session or job identifier, requested target, operation, resource identifier, authorization decision, result code, and correlation identifier.

Never log the refresh token, access token, authorization code, client secret, full callback URL, or raw Authorization header. Redaction after logging is too late if a collector, terminal recorder, or error monitor already received the event. Build logging calls that never accept those fields, rather than relying on every caller to remember a scrubber.

A useful incident query starts with a resource action and walks backward. Suppose an export appeared in the wrong destination. You should be able to answer: which job requested it, which process ran the job, which OAuth client it used, which credential version supplied access, when that version was issued, and whether another host used the same version. If any link depends on reading the token value, the audit design is broken.

Tamper evidence matters when agents act without constant supervision. An append-only log on the same host is better than silence, but an attacker who controls the host may edit both the cache and the record. Keep audit data in a protected system and verify its integrity independently. For systems that can retain an encrypted, hash-chained record, offline verification gives an investigator a way to check whether entries changed without first exposing the underlying secrets.

Approval should sit on actions, not on token exposure

Put approval on each call
Require Touch ID or one click for every use of a sensitive HTTP credential.

Human approval is most useful at the boundary where an agent tries to affect an outside system. Asking a person to approve a refresh-token file once and then letting any process use it for weeks gives the appearance of control while putting the sensitive string in circulation.

Choose approvals by consequence. A read-only retrieval may run under a pre-approved, narrowly scoped session. Sending data to a new destination, modifying permissions, deleting a resource, or using a credential outside its usual job should stop for a human decision. The approver needs to see the requesting process, target, operation, and enough parameters to understand the effect. A generic "allow OAuth" prompt is nearly useless.

Do not confuse frequent prompts with safety. If every harmless call asks for consent, people approve by muscle memory. Set a sensible session boundary for routine work, then require a separate approval for high-consequence operations. The decision needs to remain visible when the agent runs at a kitchen table, on a travel connection, or inside an overnight job.

The test is simple: if an agent instruction becomes hostile or a plugin behaves badly, can it turn access into an outside-world action without crossing a control that shows a human what will happen? If the answer is yes because it already holds auth.json, move the credential behind a broker and redesign the request surface.

Treat legacy auth.json files as a migration project

Existing token caches rarely disappear in one afternoon, but leaving them undocumented because migration is inconvenient guarantees they become permanent. Start by finding every consumer, then classify the file by service, subject, scopes, environment, writer count, and storage path. Revoke copies you cannot attribute. A token nobody owns has no business renewing itself.

Move one workflow at a time. First create a dedicated resource identity and client. Next put its refresh token behind the chosen secret store or broker. Then change the job to submit an allowed action request and compare the new audit record with the old job output. Only after the replacement works should you revoke the legacy personal grant.

Expect a few tools to fight you. Some SDKs assume they own a local JSON cache and silently refresh whenever they see it. Keep those tools in a constrained compatibility wrapper, with one process allowed to read the file and no general agent access. Put removal of that wrapper on the service owner's work queue. "The library expects it" explains a temporary exception; it does not justify a permanent secret leak path.

The first migration target should be the file with the broadest scope or the least clear owner, not the easiest file to move. Those are the credentials that turn an otherwise contained agent mistake into a production incident. Once you have moved one workflow cleanly, make the old pattern hard to repeat through deployment review, template changes, and a refusal to mount raw credential caches into agent runtimes.

A refresh token should have one accountable owner, one controlled renewal path, and an audit trail that names every outside action it authorized. If your current auth.json cannot meet those conditions, revoke it after the replacement path proves it can.

FAQ

Is an OAuth refresh token as sensitive as a password?

No. A refresh token can mint new access tokens without another interactive login, so it has ongoing power even when the access token beside it expires. Treat it as a production credential with an owner, a scope, a storage location, and a revocation path.

Can multiple AI agents share one refresh token file?

Only if the workflow has an accountable operator and the token is limited to one service identity and one environment. A shared file copied among machines turns an automation convenience into an untracked credential distribution system.

Where should a headless agent store OAuth tokens?

Use a secret store or a local credential broker that keeps the refresh token outside the agent process and returns only the action result. Restrict filesystem permissions as a backstop, not as the whole design.

Does refresh token rotation create reliability problems?

Rotating the refresh token after every use is safer only when the client records the replacement atomically. If a worker crashes after the provider invalidates the old token but before it saves the new one, the next worker may lose access and operators may reach for unsafe recovery shortcuts.

When should I rotate an OAuth refresh token?

Rotate when ownership changes, a host or repository may have exposed the file, an agent run behaves unexpectedly, or the provider reports reuse or invalid_grant errors. Scheduled rotation helps, but it does not replace revocation after a suspected leak.

What should I do if auth.json is committed to Git?

First identify the exact OAuth client, subject, scopes, and environment associated with the token. Then revoke it at the authorization server, remove local copies, inspect the service's audit records, and issue a replacement through the normal enrollment flow.

Do expired access tokens make a leaked auth.json harmless?

Expiry limits the lifetime of an access token, not necessarily the refresh token that creates another one. The refresh token may remain usable for days, months, or until the provider revokes it, depending on the provider's policy.

How do headless agents reauthorize without a browser?

A scheduled agent cannot safely complete an interactive browser login by itself. Give it a dedicated OAuth client and service identity where the provider supports that pattern, or require a human renewal path that stops work rather than borrowing someone's personal session.

What OAuth token details belong in audit logs?

Log the service identity, OAuth client identifier, granted scopes, environment, token version, host or broker that used it, and the resulting action. Do not log the refresh token, authorization code, access token, or an Authorization header.

Should an agent use a local credential gateway?

A local gateway is appropriate when agents need to call APIs but should never receive credentials. It should keep the secret in its own protected store, authenticate the requesting process, require the right human approvals, and record each call; a thin file-forwarding wrapper does none of that.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov