View source on GitHub
GITHUB_TOKEN with a token you supply. So a single “backend”
account can front many users by passing the right user’s PAT for each conversation.
But “supply a PAT and you’re done” hides three things that will bite you if you skip
them:
- Where the token is (and isn’t) readable — the sandbox has two command paths and only one of them sees the secret.
- The identity split — the push credential and the commit author are two different identities, and by default only the push side becomes the PAT owner.
- The clone trap — if you attach a repository at conversation start, it is cloned with the service account’s credentials, not the PAT.
1. The core mechanism: secrets overrides the managed token
When you start a conversation you can pass a secrets map. GITHUB_TOKEN is explicitly
on the list of overridable system secrets, so a value you pass wins over the
account’s own managed GitHub token:
- The app-server first builds the secret set from the account’s own identity: the
managed
GITHUB_TOKENis a refreshing lookup (aLookupSecretthat calls back to the app-server’s/api/v1/webhooks/secretsto fetch a freshly-minted token each time it’s read). - Your API-provided secrets are then merged last, last-write-wins.
GITHUB_TOKENbecomes a static value (your PAT), replacing the refreshing lookup. The server logsAPI-provided secret 'GITHUB_TOKEN' overrides existing secret.
- The auto-refresh no longer applies to
GITHUB_TOKEN. That’s fine for a PAT (PATs don’t need refreshing), but it means the token you inject is used as-is for the life of the conversation. If it expires or is revoked mid-run, there is no refresh path. - There is no ownership check. The platform does not verify that the PAT belongs to the calling account or to any particular user. Whoever holds the service account’s API key can make the agent act with any PAT. Treat that API key accordingly (see §7 Security).
What you may and may not override
GITHUB_TOKEN,GITLAB_TOKEN,BITBUCKET_TOKEN,AZURE_DEVOPS_TOKEN,FORGEJO_TOKEN, and the AWS Bedrock credentials are overridable by design — this BYO-credential flow is exactly what they’re for.- Names starting with
LLM_are blocked (they enforce LLM controls). - A set of container/infra names (
OH_WEBHOOKS_0_BASE_URL,OH_ALLOW_CORS_ORIGINS_0, worker ports, etc.) are blocked so you can’t repoint callbacks or break the sandbox. - Limits: a bounded number of secrets per request, bounded name length, bounded value size. Names are validated.
2. The two execution paths (this is the crux)
Inside the sandbox there are two different ways a shell command can run, and they do not have the same access to your secret:
Why:
- Conversation secrets live in the SDK’s secret registry. The registry is consulted
only by the agent’s terminal tool, and it works by scanning the command text for
registered secret names. If the literal name (e.g.
GITHUB_TOKEN) appears in the command, its value is exported as an env var for that command; otherwise nothing is injected. - The direct
POST /api/bash/...endpoint is a standalone bash service with no conversation context and no secret registry. It runs the raw command. It cannot read registry secrets no matter what the command says.
git remote set-url origin https://${GITHUB_TOKEN}@github.com/owner/repo.gitworks on the agent path — the literalGITHUB_TOKENis present, so it’s injected.gh pr create …on the agent path does not get the token — the stringGITHUB_TOKENdoesn’t appear, soghruns unauthenticated. Force it:GITHUB_TOKEN=$GITHUB_TOKEN gh pr create ….- Nested scripts, background processes, and git credential helpers only see the token if
the top-level command named it (env is then inherited by children). Once a command
in the agent’s shell session references the secret, the
exportpersists for the rest of that session.
Rule of thumb: on the agent path, name the secret in the command. On the direct path, the registry secret isn’t available at all — if you need a credential there, you must place it yourself (you hold the plaintext).
3. The identity split: push credential ≠ commit author
This is the detail most people miss.- Push credential = the token used for
git push. OverrideGITHUB_TOKENand this becomes the PAT owner. ✅ - Commit author/committer = the
user.name/user.emailbaked into the commit atgit committime. GitHub attributes a commit to an account by matching the author email. The token has no effect on this.
git_user_name / git_user_email (as git config --global). So out of the box you get:
- commits authored by the service account (or, if the account has no git identity
set, a container fallback like
root@…), and - pushed as the PAT owner.
- Your system should track each user’s git identity — specifically the GitHub-linked
email, typically the noreply form
ID+login@users.noreply.github.com— and set it in the sandbox:These are non-secret strings, so they work over either execution path. Repo-local config overrides the account’s--globaldefault. - Don’t try to discover the identity from the token unless you must —
gh api userneeds the token (so it must be on the agent path and nameGITHUB_TOKEN), adds a round trip, and adds a failure mode. Tracking it yourself is more robust.
4. The clone trap: don’t attach a repository at start
When you passselected_repository at conversation start, the app-server clones it
before your PAT is in play, using the service account’s credentials — the
service-account token is embedded directly into the origin remote URL. You’d then have
a repo cloned and remote-authenticated as the service account while the agent thinks it’s
the PAT user. Mixed identity, again.
Technique: start with no repository attached, and clone inside the session with the
PAT. With no repo selected, the app-server skips the authenticated clone entirely, and
the agent’s GITHUB_TOKEN is uniformly the injected PAT.
The clone must be agent-driven (or done by you over the direct path with the plaintext
token), because in the plain app-conversation flow the PAT only reaches the sandbox once
the conversation starts — after the app-server’s own setup step has already run. See the
sandbox-first flow below if you want to control that ordering.
5. Two implementation flows
Flow A — Simple: app-conversation with secrets (no repo attached)
Best when you’re happy to let the agent do the cloning and you don’t need setup to read
the token.
POST /api/v1/app-conversationswithsecrets: { GITHUB_TOKEN: <user PAT> }, noselected_repository, and an initial message that tells the agent to:- set git identity to the target user (pass the name/email in the message or a skill),
- clone with the PAT, e.g.
git clone https://x-access-token:${GITHUB_TOKEN}@github.com/owner/repo.git, - reference
GITHUB_TOKENexplicitly in anygh/push commands.
per-conversation-secrets
example — same secrets field, just used for GITHUB_TOKEN.
Flow B — Sandbox-first: full control over ordering
Best when you want the credential and identity in place before the agent runs, or you want to pre-clone / run setup as the correct identity.POST /api/v1/sandboxesyourself; poll toRUNNING; readsession_api_keyand theAGENT_SERVERURL fromexposed_urls.- Drive the sandbox directly over
POST /api/bash/execute_bash_command(you hold the plaintext PAT, so you’re not dependent on the registry here):git config --global user.name/user.email→ the target user’s identity,- clone the repo with the PAT, or place a credential helper / env entry,
- run any setup you need.
- Attach a conversation to the prepared sandbox:
POST /api/v1/app-conversationswith thatsandbox_id. Also register the PAT in the conversationsecretsso the agent’s own tool-path commands can use$GITHUB_TOKENby name.
start-sandbox example (create sandbox → talk to
the agent-server directly), then attaches a conversation on top.
Important nuance: injecting secrets “at start” or via the agent-server’sPOST /api/conversations/{id}/secretsendpoint both land in the conversation registry — i.e. agent-tool-path only, name-scanned. Creating the sandbox earlier does not make those registry secrets readable byPOST /api/bash/.... What the sandbox-first flow buys you is ordering plus the fact that you already hold the plaintext and can place it however you like.
6. What still resolves to the service account (residual coupling)
Even with no repo attached and the PAT injected, a few things are still the service account, because they run at conversation start regardless:- Skill loading. The app-server enumerates the service account’s login and orgs and
fetches their
.openhands/.agentsskill repos using the service account’s token. Those authenticated URLs (with the service-account token) are handed to the sandbox. If you want zero service-account credential exposure in the sandbox, consider disabling org/user skill loading for this flow, and keep the service account’s scopes minimal. - Other providers. Only
GITHUB_TOKENis overridden. AnyGITLAB_TOKEN,BITBUCKET_TOKEN, etc. still refresh against the service account. - Server-side attribution. Conversation ownership, telemetry, and webhook identity are the service account. GitHub-side actions are attributed to the PAT owner. Your audit trail is inherently split across the two systems — plan for correlating them.
7. Security and operational considerations
- The service account API key is now an “act as anyone” key. There’s no server-side check tying an injected PAT to a user. Guard the API key like the high-value credential it is; do your own authorization before choosing which PAT to inject.
- Scope the PATs. Prefer fine-grained PATs limited to the repos/permissions each operation needs. The injected token becomes readable inside the sandbox (agent path) and lives in conversation state for the conversation’s lifetime.
- Blast radius of the sandbox. Anyone with the sandbox’s
session_api_keycan drivePOST /api/bash/.... In the sandbox-first flow you place plaintext tokens there yourself — treat the sandbox as sensitive and tear it down when done. - Output masking. Secret values registered in the conversation are masked as
<secret-hidden>in tool output, but only for values the registry knows and has resolved. Tokens you place yourself over the direct path are not masked — avoid echoing them. - Prefer short-lived tokens where possible. Because the override disables the managed refresh, a long conversation with an expiring token has no recovery path; mint fresh or keep conversations bounded.
8. Implementation checklist
- Authorize the request in your system and pick the correct user’s PAT.
- Start the conversation without
selected_repository. - Pass the user’s PAT as
secrets.GITHUB_TOKEN. - Set the committer identity to the target user (
git config user.name/email, using a GitHub-linked email you track). Do it before any commit. - In agent commands, name
GITHUB_TOKENwherever the token is needed (GITHUB_TOKEN=$GITHUB_TOKEN gh …, or embed it in the clone/remote URL). - Decide clone strategy: agent-driven (Flow A) or pre-clone over the direct path (Flow B).
- Minimize service-account scopes; consider disabling org/user skill loading.
- Have a correlation strategy for the split audit trail.
- Use fine-grained, minimally-scoped, ideally short-lived PATs; tear down sandboxes.
Related
per-conversation-secrets— thesecretsfield at start vs. after start, and MCP${VAR}expansion.start-sandbox— create a sandbox and drive its agent-server REST API directly (the basis for Flow B).

