Signing in
This page covers how a person proves who they are to searchcode enterprise: through your existing single sign-on, with built-in accounts, or not at all. Agents present a different kind of credential on a different listener; they are covered under How agents sign in below and in full in Which credential do I use?.
auth.mode takes one of three values, and exactly one is active. The modes are
never combined.
auth.mode |
Who it is for | Who decides who you are |
|---|---|---|
proxy |
Any organisation with an identity provider (Okta, Microsoft Entra ID, Google Workspace, Keycloak, GitHub, any OIDC provider) | Your authenticating proxy, usually oauth2-proxy, which forwards identity headers |
local |
A deployment with no identity provider and no proxy | Built-in accounts, managed with searchcode user |
open |
A laptop evaluation | Nobody. Every caller is one anonymous principal |
Changing auth.mode requires a restart.
Signing in decides who someone is. What they can then see is decided separately, by the bindings in your configuration; see Who sees what. Both modes that sign people in feed the same authoriser, and nothing about authorisation changes with the mode.
Proxy mode
In proxy mode searchcode trusts identity headers set by a proxy in front of the UI. The proxy talks to your identity provider; searchcode never does.
Why there is no built-in OIDC
searchcode implements no OIDC client and no SAML. This is deliberate. Your proxy already owns the identity-provider conversation, and keeping it there means the searchcode process never opens a connection to an identity provider, in any mode. It also means the code that parses tokens from your provider is code your organisation already runs, patches and reviews, rather than a second implementation inside this product.
Which listener goes behind the proxy
searchcode has two listeners that matter here, on separate ports (see Ports, names and what sits in front of each listener):
- The UI listener (
server.uiListen, default port 8081) goes behind the proxy. This is where people and browsers arrive. - The API and MCP listener (
server.listen, default port 8080) must never go behind it. Agents present a searchcode-issued credential that your proxy did not issue and cannot validate. The usual workaround, a "skip authentication" rule on the proxy for/v1/mcpand/api/v1/*, is an authentication bypass living in a configuration file searchcode cannot see. Separate ports remove the need for one.
How a request is trusted
Every request to the UI listener passes these checks, in this order:
- Forged headers are stripped first, unconditionally. Every inbound copy of
the four identity headers and of the shared-secret header is removed before
anything reads them. That includes spellings with underscores
(
X_Forwarded_User), which some servers treat as the same header, and any header named in aConnectionheader. There is no setting to turn this off. - The direct peer address must be trusted. The address of the machine that
opened the connection must be inside
auth.trustedProxyCIDRs. AnX-Forwarded-Forheader never grants trust: using a header to decide whether to trust headers is circular. - The shared secret must match. The proxy sends a secret in the header named
by
auth.sharedSecretHeader, and it must equal the contents ofauth.sharedSecretFile. The comparison is constant-time. This is what stops anything else that can reach the port directly, such as another pod in the cluster or a misconfigured service, from getting anywhere. The file is re-read on a configuration reload, so a rotation takes effect without a restart. If the file is empty, every request is refused. - Each identity header must arrive at most once. A correct proxy replaces these headers. A second copy means a client's value survived somewhere on the path, so the request is refused rather than one value being picked.
- An identity must be present. A request from a trusted proxy with no
X-Forwarded-Useris refused, not treated as an anonymous visitor. Treating it as anonymous would quietly turn a protected instance into an open one.
Each refusal is written to the audit stream as auth.proxy_rejected, with a
different log code for each reason, so you can tell an untrusted peer from a
wrong secret from a duplicated header. See Audit log and
Log codes.
In proxy mode searchcode issues no session cookie of its own. Every request is authenticated from the headers, and the proxy's own cookie is the session.
The four headers
The names are fixed in the binary. There is no setting to change them.
| Header | Required | Used for |
|---|---|---|
X-Forwarded-User |
Yes | The stable subject: the identity the user meter counts and the actor in every audit record |
X-Forwarded-Email |
No | The person's email address |
X-Forwarded-Groups |
No, but you almost certainly want it | Identity groups, comma-separated. Bindings and authz.adminGroups match against these |
X-Forwarded-Preferred-Username |
No | The display name. When it is absent the UI shows the email, then the subject |
Without groups, a person matches only bindings written with groups: ["*"].
Groups are replaced, never merged
The groups a person has are exactly the groups in this request's
X-Forwarded-Groups header. They are not merged with groups from an earlier
request, from a stored record, or from anything a client sent. When someone
leaves a group in your identity provider, the change applies on their next
request that carries the new header.
Configuring searchcode
auth:
mode: proxy
trustedProxyCIDRs: [10.0.0.0/8]
sharedSecretFile: /run/secrets/proxy-shared-secret
sharedSecretHeader: X-Searchcode-Proxy-Secret
authz:
adminGroups: [searchcode-admins]
All three auth settings are required in proxy mode, and the instance refuses
to start without them. A proxy-mode instance with no trusted networks would
believe identity headers from anybody, and it would work perfectly in a test.
Make trustedProxyCIDRs as narrow as your network allows. If oauth2-proxy runs
on the same machine, bind the UI listener to 127.0.0.1 and trust
127.0.0.1/32 only.
Write the shared-secret file without a trailing newline:
printf '%s' "$(openssl rand -hex 32)" > /etc/oauth2-proxy/proxy-shared-secret
searchcode strips a trailing newline from the file it reads, but oauth2-proxy
injects the file's bytes exactly. With a newline, oauth2-proxy fails every
request with invalid header field value and nothing reaches searchcode.
The settings for local accounts (auth.local.*) are refused in proxy mode,
naming both modes, rather than silently ignored. A password-authenticated
administrator sitting beside your single sign-on would be a permanent way around
the control you believe is in force. If the proxy breaks, the way back in is
the searchcode user command on the host, not a second web login.
Configuring oauth2-proxy
The tarball ships a worked configuration in systemd/: oauth2-proxy.cfg,
oauth2-proxy-alpha.yaml and oauth2-proxy.service. Each setting carries a
comment saying why it is set. The Helm chart covers the Kubernetes case; see
Install with Helm and
Install with systemd.
The shape that matters:
# oauth2-proxy.cfg
upstreams = ["http://127.0.0.1:8081/"] # the UI listener, and only that
provider = "oidc"
oidc_issuer_url = "https://idp.example.com/realms/main"
client_id = "searchcode"
scope = "openid email profile groups"
redirect_url = "https://searchcode.example.com/oauth2/callback"
email_domains = ["example.com"]
pass_user_headers = true
cookie_secure = true
Never write the client secret or the cookie secret into the file. Point
oauth2-proxy at files instead, through OAUTH2_PROXY_CLIENT_SECRET_FILE and
OAUTH2_PROXY_COOKIE_SECRET_FILE in the systemd unit's environment file.
oauth2-proxy v7 can add the shared secret itself, so you need no nginx or other
proxy just for that. Use the alpha configuration that ships beside the .cfg:
# oauth2-proxy-alpha.yaml
injectRequestHeaders:
- name: X-Forwarded-User
values:
- claimSource:
claim: user
- name: X-Forwarded-Email
values:
- claimSource:
claim: email
- name: X-Forwarded-Preferred-Username
values:
- claimSource:
claim: preferred_username
- name: X-Forwarded-Groups
values:
- claimSource:
claim: groups
- name: X-Searchcode-Proxy-Secret
values:
- secretSource:
fromFile: /etc/oauth2-proxy/proxy-shared-secret
Run it with --config oauth2-proxy.cfg --alpha-config oauth2-proxy-alpha.yaml,
and delete the block in the .cfg that is marked as moving into the alpha file.
oauth2-proxy refuses to start with the same setting in both. The shipped files
document the remaining pitfalls, including the one where the proxy appears to
start and then listens on nothing.
A person in several groups is fine: oauth2-proxy sends them as one
comma-separated X-Forwarded-Groups header, which is what searchcode expects.
Provider notes
Whatever the provider, sign in once and open /account. It lists the groups
searchcode received, exactly as they arrived. Name groups in bindings and in
authz.adminGroups exactly as they appear there, including case.
| Provider | oauth2-proxy provider |
Scope | Where groups come from |
|---|---|---|---|
| Okta | oidc |
openid email profile groups |
The groups claim |
| Keycloak | oidc |
openid email profile groups |
The groups claim |
| Microsoft Entra ID | oidc |
openid email profile |
The group claim configured on the app registration |
| Google Workspace | google |
openid email profile |
The Workspace Directory API, through --google-group, --google-admin-email and --google-service-account-json |
| GitHub | github |
user:email read:org |
Organisation and team membership |
| Any other OIDC provider | oidc |
Whatever your provider names its groups scope | The claim your provider uses |
Google has no groups scope. Requesting one fails before any sign-in prompt
with Error 400: invalid_scope. Use the short scope. Use the google provider,
not oidc: the options that fetch groups from Google exist only on the google
provider.
GitHub groups arrive as the organisation name, and as
organisation:team-slug for each team. A member of one team has two groups.
Without read:org, members sign in with no groups and see nothing they were not
granted through groups: ["*"].
Choose the scope that fails loudly. The shipped default includes groups.
On a provider without that scope, the sign-in stops immediately with an error.
The opposite mistake, a short scope on a provider that does have groups, signs
everyone in successfully and grants them almost nothing.
Local accounts
auth.mode: local keeps accounts in the instance's own store, subjects.db
under instance.dataDir. Passwords are hashed with Argon2id.
There is no password setting in searchcode.yaml, no default password, and no
account at all until you create one. There is no email and no self-service
reset: this product sends no mail, and a reset flow that depends on one fails at
exactly the moment somebody is locked out. Resetting a password is an
administrator action on the host.
The first administrator
Interactively, on the host:
searchcode user add --admin alice
--admin puts the account in the first group listed in authz.adminGroups, so
that list must name at least one group:
auth:
mode: local
authz:
adminGroups: [searchcode-admins]
For an unattended install, set an initial administrator instead. The password is read from a file or an environment variable, never from the configuration file, and the administrator must change it at first sign-in because the value stays in your deployment manifest:
auth:
mode: local
local:
initialAdminUsername: admin
initialAdminPasswordFile: /run/secrets/initial-admin-password
Set initialAdminPasswordFile or initialAdminPasswordEnv, not both.
Passwords
A password must be at least 12 characters. There are no composition rules and no
forced rotation, following NIST SP 800-63B: length matters, and complexity rules
produce Password1!.
A password that an administrator set from a file or an environment variable must be changed at the next sign-in. Until it is changed, no session is issued at all. A password typed at the interactive prompt does not force a change.
The hashing cost is auth.local.argon2. The defaults are the OWASP
recommendation (19 MiB of memory, two passes, one lane). Raising them later
invalidates nothing, because every stored hash carries the parameters it was made
with. Validation refuses values low enough to make the store cheap to crack.
Failed sign-ins slow down; they never lock an account
Usernames are visible in the commit history searchcode indexes. Locking an account after a few failures would therefore give anyone a way to lock out any named engineer. So searchcode slows attempts down instead:
- Per source address:
auth.local.loginRatePerMinuteattempts a minute (default 10), checked before the password hash runs, so an unauthenticated caller cannot make the instance spend unlimited CPU. After three consecutive failures, the source is refused for a period that doubles from one second, up to five minutes. - Per account: each failure adds a delay before the next check, doubling from 50 milliseconds up to two seconds. It is a delay, never a refusal, so the real owner still gets in.
A successful sign-in clears both.
Managing accounts
Every command works with the server stopped, which is the recovery path if the last administrator is locked out. They also work while the server runs: the running instance re-reads the store every second.
| Command | What it does |
|---|---|
searchcode user add NAME [--admin] [--groups a,b] |
Creates an account |
searchcode user list |
Lists accounts, and which are administrators |
searchcode user passwd NAME |
Resets a password |
searchcode user groups NAME --set a,b |
Replaces an account's groups. Replaces, never merges |
searchcode user disable NAME |
Disables the person, ends every session and revokes every credential they hold, within a second |
searchcode user enable NAME |
Re-enables them |
--password-file PATH and --password-env VAR read a password without a prompt.
Every command that changes something writes an audit record naming the operating-system user who ran it.
disable and enable also work in proxy mode, given the person's subject (the
value of X-Forwarded-User) instead of a username. A disabled person is refused
even when your proxy vouches for them.
Sessions
A local-mode session lasts 12 hours. Sessions are held in memory, so a restart
signs everyone out of the browser. The session cookie is __Host- prefixed,
Secure and HttpOnly, and every state-changing form carries a CSRF token.
Open mode
auth.mode: open has no sign-in. Every caller is the same anonymous principal,
which receives the role in authz.anonymousRole (default viewer). The
anonymous principal is never an administrator, and it still goes through the
authoriser like anyone else.
Open mode is for evaluating on your own machine. The startup line says so
plainly, and the Helm chart refuses to render it unless auth.allowAnonymous is
set to true. authz.anonymousRole is refused in the other two modes.
Administrators and auditors
Administration is not part of any binding. It comes from two instance-wide lists of identity groups:
authz.adminGroups: full administrative access, including the admin screens.authz.auditorGroups: the audit log and usage analytics.
An auditor who should also read code needs a binding as well. See Who sees what.
In proxy mode these are groups your identity provider sends. In local mode they
are groups on the account. If authz.adminGroups is empty, nobody is an
administrator.
Plain http and the session cookie
Because the local-mode session cookie is Secure, a browser discards it over
plain http:// on anything but localhost. A sign-in over plain http on a LAN
address therefore succeeds and then returns to the sign-in form. searchcode
detects this and says so on the page and in the log.
The fixes, in order of preference:
- Evaluate on
http://localhost:8081. A browser treats localhost as secure. - Put TLS in front of the UI listener, and set
mcp.oauth.uiUrlto the https address browsers use. - For a throwaway trial on a private network only, set
auth.session.insecureCookiesOverPlainHttp: true. The session identifier then travels in clear text, and anyone on the network path can take it. The instance announces this at every start.
In proxy mode the cookie is oauth2-proxy's, and cookie_secure in its
configuration is the setting that matters.
How agents sign in
Agents connect to the API and MCP listener, which no proxy fronts. They never present your identity provider's tokens. They use one of two searchcode-issued credentials (details in Which credential do I use?):
- OAuth, the default for MCP clients. The client registers itself, opens a
browser at the UI listener, and the person approves. Because the UI listener
sits behind your proxy in proxy mode, approving goes through your normal
single sign-on. Approval is a form submission carrying the session's CSRF
token, never a link that approves on its own. The client then holds a
short-lived access token (
mcp.auth.tokenTtl, default 8 hours) and refreshes it silently. - A personal API key, minted on
/account, for a client that cannot sign in.
A token refresh never passes through your proxy. So in proxy mode a refresh is allowed only if the proxy has vouched for that person in a browser within the last seven days. Someone who opens the UI at least once a week never notices. Someone disabled in your identity provider loses their agents within a week of their last visit. In local mode a disabled account's refresh fails at once.
Connect an agent client has the configuration for each client.
Checking what is in force
- At startup, the instance prints one line on stderr beginning
searchcode auth:, naming the mode, the trusted networks, the shared-secret header and the administrator groups. In open mode it says that there is no login. /accountshows who you are signed in as and the groups searchcode received for you.- Explain-access answers why a particular person can or cannot see a repository, for administrators and auditors. See Who sees what.
searchcode doctorflags the configurations known to cause sign-in failures. See searchcode doctor.- Every setting is in the configuration reference.