> ## Documentation Index
> Fetch the complete documentation index at: https://docs.openhands.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Service Account GitHub PAT

> Use one OpenHands account as a service account, overriding the managed GITHUB_TOKEN with each user's PAT per conversation.

<Card title="View source on GitHub" icon="github" href="https://github.com/OpenHands/enterprise-cookbook/tree/main/service-account-github-pat" horizontal />

**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:

```jsonc theme={null}
POST /api/v1/app-conversations
{
  "sandbox_id": "…",
  "initial_message": { "role": "user", "content": [{ "type": "text", "text": "…" }] },
  "secrets": {
    "GITHUB_TOKEN": "ghp_thePatOfTheUserYouAreActingFor"
  }
}
```

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](#7-security-and-operational-considerations)).

### 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:

| | Agent tool-call path | Direct exec path |
| - | - | - |
| Who triggers it | the agent (LLM) running its `bash` tool | you, calling the agent-server REST API |
| Endpoint | conversation `events` → tool executor | `POST /api/bash/execute_bash_command` |
| Sees conversation secrets? | **Yes, but only if the command text names the secret** | **No — never** |

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:
  ```bash theme={null}
  git config user.name  "Jane Example"
  git config user.email "12345+jane@users.noreply.github.com"
  ```
  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`](/cookbook/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`](/cookbook/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.

***

## Related

* [`per-conversation-secrets`](/cookbook/per-conversation-secrets) — the `secrets` field at
  start vs. after start, and MCP `${VAR}` expansion.
* [`start-sandbox`](/cookbook/start-sandbox) — create a sandbox and drive its agent-server REST
  API directly (the basis for Flow B).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.