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

How a request is trusted

Every request to the UI listener passes these checks, in this order:

  1. 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 a Connection header. There is no setting to turn this off.
  2. The direct peer address must be trusted. The address of the machine that opened the connection must be inside auth.trustedProxyCIDRs. An X-Forwarded-For header never grants trust: using a header to decide whether to trust headers is circular.
  3. The shared secret must match. The proxy sends a secret in the header named by auth.sharedSecretHeader, and it must equal the contents of auth.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.
  4. 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.
  5. An identity must be present. A request from a trusted proxy with no X-Forwarded-User is 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:

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:

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.

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:

  1. Evaluate on http://localhost:8081. A browser treats localhost as secure.
  2. Put TLS in front of the UI listener, and set mcp.oauth.uiUrl to the https address browsers use.
  3. 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?):

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

Generated from the documentation that ships with searchcode enterprise. Questions: ben@boyter.org