Skip to main content

View source on GitHub

Question: Can I use one OpenHands SaaS account as a service account that performs GitHub operations on behalf of many different users, by supplying each user’s GitHub Personal Access Token (PAT) as a per-conversation secret? Short answer: Yes. OpenHands lets a conversation-start request override the account’s own managed 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:
  1. Where the token is (and isn’t) readable — the sandbox has two command paths and only one of them sees the secret.
  2. 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.
  3. The clone trap — if you attach a repository at conversation start, it is cloned with the service account’s credentials, not the PAT.
This guide explains the mechanics and the techniques to get it right.

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:
What happens server-side:
  • The app-server first builds the secret set from the account’s own identity: the managed GITHUB_TOKEN is a refreshing lookup (a LookupSecret that calls back to the app-server’s /api/v1/webhooks/secrets to fetch a freshly-minted token each time it’s read).
  • Your API-provided secrets are then merged last, last-write-wins. GITHUB_TOKEN becomes a static value (your PAT), replacing the refreshing lookup. The server logs API-provided secret 'GITHUB_TOKEN' overrides existing secret.
Two consequences worth internalizing:
  • 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.
Practical fallout:
  • git remote set-url origin https://${GITHUB_TOKEN}@github.com/owner/repo.git works on the agent path — the literal GITHUB_TOKEN is present, so it’s injected.
  • gh pr create … on the agent path does not get the token — the string GITHUB_TOKEN doesn’t appear, so gh runs 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 export persists 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. Override GITHUB_TOKEN and this becomes the PAT owner. ✅
  • Commit author/committer = the user.name / user.email baked into the commit at git commit time. GitHub attributes a commit to an account by matching the author email. The token has no effect on this.
By default the app-server configures git identity from the service account’s stored 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.
That split pollutes contribution graphs, “Verified”/linked authorship, and your audit trail. If attribution matters, set the committer identity to the target user yourself.
  • 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 --global default.
  • Don’t try to discover the identity from the token unless you must — gh api user needs the token (so it must be on the agent path and name GITHUB_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 pass selected_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.
  1. POST /api/v1/app-conversations with secrets: { GITHUB_TOKEN: <user PAT> }, no selected_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_TOKEN explicitly in any gh/push commands.
This is a superset of the 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.
  1. POST /api/v1/sandboxes yourself; poll to RUNNING; read session_api_key and the AGENT_SERVER URL from exposed_urls.
  2. 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.
  3. Attach a conversation to the prepared sandbox: POST /api/v1/app-conversations with that sandbox_id. Also register the PAT in the conversation secrets so the agent’s own tool-path commands can use $GITHUB_TOKEN by name.
This builds on the 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’s POST /api/conversations/{id}/secrets endpoint 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 by POST /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 / .agents skill 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_TOKEN is overridden. Any GITLAB_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_key can drive POST /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_TOKEN wherever 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.

  • per-conversation-secrets — the secrets field 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).