Which credential do I use?
There are three ways in. Picking the wrong one is not an error you notice on the day — you notice it weeks later, when the audit log cannot answer a question or an integration breaks because the person who created it left.
Ask one question: is there a human on whose behalf this acts?
| You are | Use | Because |
|---|---|---|
| A developer wiring up Claude Code, Cursor, or any MCP client | OAuth — claude mcp add, then a browser login |
Nothing to paste, and your groups refresh every session |
| A developer whose agent platform does not speak MCP, or who is scripting against the REST API | A personal API key from /account |
Acts as you, rolls up to your seat, audited as your agent |
| Building a backend integration — a dashboard, a nightly job, an internal tool | A service account, declared in config by an operator | Its own permissions, its own seat, audited as service rather than impersonating a person |
The first two answer yes, me. The third answers no, nobody — and a service account is the only correct answer to that, because everything else on this page acts as a person who will one day leave.
There is no deployment-wide key
Every agent call carries a credential that resolves to a real principal, and authorisation is evaluated per call against that principal's groups. There is no shared MCP key, no service token in a config file that everybody uses, and no way to create one.
Credentials are issued per (user, client) — not merely per user. Each is named at creation, individually revocable, and carries last-used tracking. That is what lets the audit trail say which agent acted, rather than only which person.
OAuth — the default for MCP clients
claude mcp add --transport http searchcode-private https://searchcode.example.com/v1/mcp
searchcode-private is a name local to your client and must be unique in that file: pasting over an entry that already has it replaces it silently, with no merge and no warning. It deliberately does not collide with searchcode, which is what searchcode.com publishes for the hosted product. Rename it to whatever you call this instance, or set mcp.serverName so every reader is handed your name instead.
Cursor, VS Code, Codex, OpenCode, Claude Desktop and Devin Desktop each spell this differently; Connect an agent client has every one, and /account shows them filled in for your instance.
The client registers itself, opens a browser, you approve, and it holds a token. Nothing is pasted anywhere.
- Approval is a POST carrying your session's CSRF token, never an auto-approving GET. Dynamic registration means anybody can register a client with their own redirect URI, so an auto-approving GET would let a page you merely visit obtain a code for your session — and PKCE would not help, because the attacker holds the verifier.
- Redirect URIs exact-match. S256 only. Codes are single-use with a 60-second life. Refresh tokens rotate.
- Groups re-resolve on refresh. A narrower group set narrows the grant at the next refresh, which is the property that makes this the best of the three for offboarding.
- A restart does not sign you out. The refresh token survives it (as a
digest in
subjects.db); the access token does not, and the client refreshes without asking you anything. - Behind a sign-in proxy, open the UI once a week. A refresh never passes the proxy, so it is allowed only while the proxy has vouched for you in a browser within the last seven days. Past that, the client is told to sign in through the browser again.
mcp.auth.tokenTtl (default 8h) is the revocation latency this buys you in
auth.mode: local. Behind a proxy, an identity-provider disable takes effect
within seven days of the person's last browser visit; searchcode user disable
takes effect at the next refresh in either mode.
A personal API key
Create it at /account in the UI. It is shown once.
curl -H "Authorization: Bearer $SEARCHCODE_TOKEN" \
https://searchcode.example.com/api/v1/repos
It acts as you, with your groups as they were at the moment of issue, evaluated against the operator's live authorisation bindings. It rolls up to your seat — every credential a person holds counts as that one person, so there is no metering reason to share one.
The REST API is a first-class surface, not a fallback. It is the same API the
UI and the MCP tools both consume; nothing reaches the index any other way. Not
every agent platform speaks MCP, and reaching for curl is not a downgrade.
Two warnings, both discovered late by the people who ignore them:
Do not share a personal key between integrations
A key is issued per (user, client) precisely so that a leak in one place can be revoked without breaking the others, and so the audit trail can name which one acted. A key pasted into four places forfeits both properties: you cannot revoke it without an outage you cannot scope, and every record it produces says only "alice's key" when the question is "which of alice's four tools did that".
Create a second key. They are free, they are named, and /account lists them
with last-used times so the dormant ones are visible.
Do not use a personal key for an unattended integration
When that person leaves, their key is revoked as part of offboarding and the integration breaks — at the worst possible moment, with nobody left who understands why. This is the single most common cause of a mysterious 401 six months into a deployment.
That is what service accounts are for. If no human is on whose behalf the thing acts, do not borrow one.
A service account
Declared in config by an operator, never created in the UI. What follows is the whole procedure, from nothing to a verified integration; why each step is the way it is lives in the service accounts article.
Give an application access
1. Decide the name and the repository groups. The name is what every audit
record shows as the acting principal, so name the application, not the team.
Access is granted per repository group — one of the repositoryGroups this
instance declares — never per repository, and never by pattern.
2. Generate the token into a file, on the server. It must be random. Use this, and nothing a person chose:
sudo sh -c 'openssl rand -hex 32 > /etc/searchcode/sa-platform-dashboard-token'
sudo chown root:searchcode /etc/searchcode/sa-platform-dashboard-token
sudo chmod 0640 /etc/searchcode/sa-platform-dashboard-token
Writing it straight to a file on the server means the secret never passes
through a terminal scrollback, a chat window or a ticket on the way. A trailing
newline is stripped when the file is read. tokenEnv works instead of
tokenFile where secrets arrive as environment variables.
3. Declare it.
serviceAccounts:
- name: platform-dashboard
groups: [platform] # REPOSITORY groups, not identity groups
role: viewer
tokenFile: /etc/searchcode/sa-platform-dashboard-token
groupsare repository groups, named literally. A glob is refused, and so is a group this instance does not declare.roleis the grant over those groups.vieweris every read — search, files, history, blame, the REST API and MCP. A customauthz.rolesentry narrows it, for example to the REST API without MCP.- Every name and every token must be unique, and validation refuses a
repeat — the same name, the same
tokenFileortokenEnv, or two different files that hold the same token — naming both entries.
4. Validate, as the user the service runs as.
sudo -u searchcode searchcode --config /etc/searchcode/searchcode.yaml config validate
Running it as searchcode is what proves the token file is readable by the
process that will read it. An unreadable or empty token file fails validation
naming the path.
5. Let it load. Service accounts are live: the running instance picks the
change up on its next configuration poll, with no restart. A configuration the
instance refuses keeps the previous one serving and says so on /admin.
6. Hand the token over through your secret manager — never chat, email or a ticket — and have the application send it on every call, as either header:
curl -H "Authorization: Bearer $TOKEN" https://searchcode.example.internal/api/v1/...
curl -H "X-Api-Key: $TOKEN" https://searchcode.example.internal/api/v1/...
The same token works on the REST API and on MCP (/v1/mcp).
7. Verify from the application's side.
curl -H "Authorization: Bearer $TOKEN" https://searchcode.example.internal/api/credentials/self
It should answer "type": "service", the account's name, and exactly the
repositories you meant. ?repo=<name> explains why one repository is or is not
reachable.
Change, rotate and revoke
| To | Do | Takes effect |
|---|---|---|
| Change what it can reach | Edit its groups or role |
Next request after the poll |
| Revoke it | Delete its entry | Next request after the poll |
| Rotate its token | Write the new token to a new file and point tokenFile at it |
Next request after the poll |
Rotating by overwriting the same file does nothing until a restart. The instance watches the configuration files, not the secret files they point at, so a new value behind an unchanged path is never noticed. Point at a new file, or restart.
Three things to know before you rely on one
- It uses its own seat. On an instance already at its licensed user count, a
new service account's first call is refused; once it has been seen it is not
refused again for twelve months. Check the seat count on
/adminfirst. - Its token never expires. It lasts until you rotate or revoke it, so put the rotation in somebody's calendar.
- Nothing ties the token to where it is used from. If the application calls from fixed addresses, restrict the API hostname to them at the network — see the article's section on reaching the API.
Which credential is actually in play?
GET /api/credentials/self on the API and MCP listener answers it, for whatever
credential the request presented:
{
"subject": "jbloggs",
"type": "agent",
"source": "apikey",
"client": "Claude Code - work laptop",
"groups": ["engineering"],
"repositories": ["acme/payments", "acme/web"]
}
type and source together name the mechanism, and the three do not overlap:
| What is authenticating | type |
source |
|---|---|---|
| A person's API key | agent |
apikey |
| An OAuth access token | agent |
oauth |
| A service account | service |
apikey |
client is the name somebody typed at issue for a key, and the OAuth client id
for a token. repositories is what this credential reaches, from the same
AllowedRepos every other surface uses. Add ?repo=<name> to ask about one:
a repository this credential cannot reach answers 403 with the reason,
rather than simply being absent from the list, because an absence is not a
diagnosis.
It is deliberately not under /api/v1/. Credential introspection is plan
12's, not the query API's, so it does not appear in openapi.json or at
/api/v1/docs, both of which are generated from the query API's definition
table. /api/v1/me answers the same question about the PERSON and carries the
same source field.
Before this response field existed, this was the only surface that knew, and it was documented nowhere: a trial answered "which credential is in force" by listing its MCP client's private credential store and noticing that searchcode had logged no OAuth traffic.
Lifetimes
api.apiKeys.ttl.max bounds how long a static key may live, and it defaults to
90 days deliberately: a static key survives an identity provider disable until it
is revoked or expires, so a high ceiling makes the manual offboarding step
load-bearing. Raise it only with that trade in mind.
api.apiKeys.requireName defaults to true — an unnamed key is unauditable.
api.apiKeys.allowNoExpiry defaults to false, because a non-expiring credential
is a finding in any audit.
Offboarding
Read security/questionnaire.md#offboarding before you write your offboarding runbook. The short version is that it is a two-step, and the second step is not optional.
What bounds a credential
Its issuer's groups, and its lifetime. There is no scope field, and that is deliberate rather than missing: the HTTP API is the single surface and the MCP tools are thin wrappers over it, so a surface scope would be trivially bypassable. A credential cannot grant more than the person or account it was issued for has, and it never grants a permission that person does not.