Skip to content

Reference

Authentication

Authentication in Exofind verifies API requests using bearer tokens in the Authorization header. Exofind reads credentials only from the Authorization header, never from cookies or query parameters. Cross-Origin Resource Sharing (CORS) is a policy setting rather than a security boundary because requests carry no ambient credentials.

A request presents an API key as a bearer token in the Authorization header:

POST /v1alpha1/indexes/books/search
Authorization: Bearer exok_4ff6b760264c1918_ePQcdT1O9HSATZoXfDbT8hhHGsP9VpZH

The EXOFIND_AUTH_MODE environment variable controls the authentication mode for a node:

ModeDescription
keysRequires every request to present a credential, which is checked against deployment keys and the node’s root key. This is the default.
noneDisables authentication checks and allows all requests. Development mode uses this setting. Other deployments must specify it explicitly.

A node configured in keys mode refuses to start if it cannot read stored keys and has no root key configured.

Keys are stored alongside indexes: as an object in the storage bucket in object mode, or as a file beside the indexes in local mode. Every node reads this storage and updates keys conditionally based on the version read. Key synchronization behaves as follows:

  • A key created on one node works on all nodes. Revoking a key takes effect across all nodes without redeployment.
  • Revocation takes effect within the duration configured by EXOFIND_AUTH_REFRESH_INTERVAL. A node accepts a cached key until its next storage read. A node looks up an unseen key immediately, so newly created keys work without delay.
  • Key management does not depend on a specific node. Requests to /v1alpha1/admin/keys are handled directly by the node that receives them and are not forwarded to the indexer.

In local mode, the single node creates the key file readable only by the user running the node, regardless of the process umask.

If a node configured in object mode cannot access object storage for keys, it acts as though no keys exist. In this state, only the root key can authenticate requests.

A key contains grants. Each grant combines a set of permissions with a set of index patterns. Every permission in the grant applies to every matching index.

Exofind supports the following permissions:

PermissionScopeDescription
searchindexExecutes search queries using POST /v1alpha1/indexes/{name}/search.
documents.readindexReads documents back out of an index using GET /v1alpha1/indexes/{name}/documents.
documents.writeindexAdds documents to an index.
documents.deleteindexDeletes documents by key or by query.
indexes.readindexLists indexes, reads index definitions and status, and views writer node assignments.
indexes.writeindexCreates an index or generation, or replaces an index definition.
indexes.deleteindexDeletes an index or generation.
indexes.promoteindexPromotes an index to serve from a generation.
indexes.commitindexCommits and pushes pending changes.
indexes.pullindexPulls the latest index state.
indexes.reindexindexStarts and cancels reindex jobs that fill a generation from another one. A job that promotes the generation it fills also needs indexes.promote.
settings.writeindexReplaces or removes the search settings of an index. Kept apart from indexes.write so relevance tuning can be granted without the power to change what an index contains. Reading settings needs indexes.read.
keys.readdeploymentLists API keys.
keys.writedeploymentCreates and revokes API keys.
registry.auditdeploymentCompares the index registry with what the storage holds.
registry.repairdeploymentRebuilds the index registry from what the storage holds.

Grants are evaluated as a union: a request is allowed if any grant permits it. There are no deny rules.

An index pattern is either the exact name of an index or a prefix followed by an asterisk (*). A single asterisk (*) matches all indexes. Deployment-scoped permissions apply regardless of specified index patterns.

Permission names are stored inside keys and are immutable.

Permission and role names are matched exactly. Both are lowercase, and a name in any other case returns auth:key:permission_unknown or auth:key:role_unknown.

Two requests do more than the permission on their path suggests. Both promote a generation, so both need indexes.promote in addition to the permission the endpoint requires:

RequestRequires
POST /v1alpha1/admin/indexes/{name}/actions/reindex with "promote": "auto", which is the defaultindexes.reindex and indexes.promote
PUT /v1alpha1/admin/indexes/{name}?reindex=autoindexes.write and indexes.promote

Passing "promote": "manual" or reindex=manual starts a job that stops in the ready phase without promoting anything, and needs only the permission the endpoint requires. Finishing such a job with POST /v1alpha1/admin/indexes/{name}/actions/promote needs indexes.promote.

Generations are named in the format index@generation. Index patterns match generations as follows:

PatternDescription
productsMatches the products index, but no named generation of it.
products@*Matches every generation of products, but not the products index itself.
products*Matches the products index, its generations, and any other index whose name starts with products.

The @ character cannot be used in index names, so a pattern matching generations of one index cannot match another index.

For example, granting products allows an application to query the index across rollouts without permitting access to specific generations. Granting products@* allows a rollout process to manage generations of products without granting access to other indexes.

Roles provide shorthand sets of permissions when creating keys:

RolePermissions
readersearch, indexes.read
writersearch, indexes.read, documents.read, documents.write, documents.delete, indexes.commit
adminAll permissions, including key management (keys.read, keys.write)

When a key is created, roles are expanded into their constituent permissions. Only the resulting permissions are stored in the key. Existing keys do not change permissions if role definitions change in later software versions. The writer role does not include indexes.write and cannot modify index definitions.

When a request fails authorization, the server responds based on key permissions:

  • If a key has no grants matching an index, the server returns 404 Not Found and omits the index from GET /v1alpha1/admin/indexes.
  • If a key matches an index pattern but lacks the required permission for the operation, the server returns 403 Forbidden and names the missing permission.

The server returns the following authentication and authorization HTTP status codes:

StatusCodeDescription
401 Unauthorizedauth:unauthenticatedThe request contains no credential, or the credential is malformed, unknown, or expired. The response includes a WWW-Authenticate: Bearer header.
403 Forbiddenauth:forbiddenThe authenticated caller lacks the required permission for the requested action.
404 Not Foundindex:not_foundThe requested index does not match any index pattern in the key.

The EXOFIND_AUTH_ROOT_KEY environment variable defines a per-node credential with full administrative permissions. The root key is not stored in key storage and cannot be listed or revoked through the API.

The value can be the plain text key string or its SHA-256 hash formatted as sha256:<hex>, where <hex> is 64 hexadecimal characters. A node refuses to start when the value after sha256: is not a hash, because that root key could never match.

To generate a root key value, run:

Terminal window
openssl rand -base64 32

Answering requests that carry no credential

Section titled “Answering requests that carry no credential”

The EXOFIND_AUTH_ANONYMOUS_KEY environment variable specifies a key ID for unauthenticated requests. When configured on a node, requests without an Authorization header execute with the permissions of the specified key. A request with an Authorization header that is not a bearer token, or with an empty bearer token, is rejected with 401 Unauthorized rather than answered as the anonymous key. When unset, unauthenticated requests are rejected.

Anonymous keys have the following restrictions:

  • A node refuses to start if the referenced key contains any permission other than search.
  • Permissions other than search are omitted at request evaluation time if the referenced key is modified after node startup.

For more information on configuring demo environments, see Run a public demo node.

The keys API manages deployment API keys under /v1alpha1/admin/keys. What a key allows cannot be changed: to grant something else, create a replacement key and revoke the old one. The credential of a key can be replaced on its own.

The API provides the following endpoints:

GET /v1alpha1/admin/keys # List all keys and node configuration
POST /v1alpha1/admin/keys # Create a key
DELETE /v1alpha1/admin/keys/{id} # Revoke a key
POST /v1alpha1/admin/keys/{id}/actions/rotate # Replace the credential of a key

Each endpoint also has a generated page stating every field it accepts and returns. See API keys.

To create a key, send a POST request to /v1alpha1/admin/keys:

POST /v1alpha1/admin/keys
Content-Type: application/json
{
"description": "the search backend",
"grants": [
{ "role": "reader", "indexes": ["books", "movies-*"] },
{ "permissions": ["documents.write"], "indexes": ["events"] }
],
"expiresAt": "2027-01-01T00:00:00Z"
}

The request body supports the following fields:

  • description (optional): A string describing the key.
  • grants (required): An array of grant objects. Each grant specifies role, permissions, or both (evaluated as a union). An empty permissions array is refused, the same as a grant that specifies neither field. The indexes array is required for grants containing index-scoped permissions, and is refused on a grant that holds only deployment-scoped permissions, because those apply whatever the patterns say.
  • expiresAt (optional): An ISO 8601 timestamp string defining when the key expires. If omitted, the key does not expire. A timestamp that has already passed is refused, because the credential returned would be refused as lapsed by the next request that presented it.

A successful request returns 201 Created with the generated credential string and key metadata. The full secret credential is returned only once in this response:

{
"credential": "exok_4ff6b760264c1918_ePQcdT1O9HSATZoXfDbT8hhHGsP9VpZH",
"key": {
"id": "4ff6b760264c1918",
"description": "the search backend",
"grants": [
{ "permissions": ["indexes.read", "search"], "indexes": ["books", "movies-*"] },
{ "permissions": ["documents.write"], "indexes": ["events"] }
],
"createdAt": "2026-08-16T12:09:33.198275Z",
"expiresAt": "2027-01-01T00:00:00Z"
}
}

Key secrets are stored only as hashes. A lost credential cannot be recovered and must be replaced. Server logs record the key id, never the credential value.

To list keys, send a GET request to /v1alpha1/admin/keys:

{
"keys": [ { "id": "...", "grants": [], "createdAt": "..." } ],
"rootKeyConfigured": true,
"anonymousKey": "fe3747c2761ef89d"
}

The keys array contains deployment keys shared across all nodes. The rootKeyConfigured and anonymousKey fields reflect the local configuration of the node answering the request. The anonymousKey field is always present in the response and is null when the node rejects unauthenticated requests. A key’s expiresAt is always present and is null when the key does not expire. The permissions of a grant are returned sorted by name, whatever order they were given in when the key was created.

A node that cannot store keys returns 409 Conflict with the code storage:unavailable instead of an empty list, so an empty keys array means the deployment holds no key.

The listing takes the prefix, limit and after query parameters every admin listing takes. prefix matches the key ID. A listing cut short by limit carries the last ID in next; pass it as after to read on. The rootKeyConfigured and anonymousKey fields are answered on every page.

To replace the credential of a key without changing what the key allows, send a POST request to /v1alpha1/admin/keys/{id}/actions/rotate. The response has the same shape as key creation:

{
"credential": "exok_4ff6b760264c1918_yLmc0Uo6BxfR2wPnEvKa7dHTqJ9sZgXV",
"key": {
"id": "4ff6b760264c1918",
"description": "the search backend",
"grants": [ { "permissions": ["indexes.read", "search"], "indexes": ["books"] } ],
"createdAt": "2026-08-16T12:09:33.198275Z",
"expiresAt": "2027-01-01T00:00:00Z"
}
}

The ID, description, grants, createdAt, and expiresAt are all kept, so anything that records the key ID stays correct. Only the secret changes.

The previous credential stops working on the answering node immediately and on every other node within EXOFIND_AUTH_REFRESH_INTERVAL. That interval is the window in which clients can move to the new credential.

A node answers 409 Conflict for two keys, because it would not start again without them:

  • The last key granted keys.write, on a node with no root key. Nothing would be able to create another key. Create a replacement key first, or set EXOFIND_AUTH_ROOT_KEY.
  • The key named by EXOFIND_AUTH_ANONYMOUS_KEY on the answering node. Point that variable at another key, or unset it, and send the request again.

Both are read from the configuration of the node answering the request. On a deployment whose nodes are configured differently, a node that does not depend on the key revokes it.

The keys API returns the following error status codes:

StatusCodeDescription
400 Bad Requestauth:key:*The request specifies an unknown role, permission, or index pattern, a grant that grants nothing, indexes on a grant that reaches no index, or a timestamp in expiresAt that is invalid or has passed. All validation errors are reported.
404 Not Foundauth:key:not_foundThe specified key ID does not exist for revocation or rotation.
409 Conflictauth:key:last_administratorThe key is the last one granted keys.write and the node has no root key.
409 Conflictauth:key:in_use_as_anonymousEXOFIND_AUTH_ANONYMOUS_KEY names this key on the answering node.
409 Conflictstorage:unavailableKey storage is unavailable because object storage cannot be used for keys on this node.
409 Conflictstorage:io_errorKey storage could not be reached. Stored keys are unchanged.
409 Conflictstorage:conflictConcurrent updates from other nodes conflicted with this request. Stored keys are unchanged.

A stored key can include a required_features list. If a node does not recognize a feature named in required_features, it rejects the key completely.

The required_features field is used for features that narrow key permissions. Because grants are additive, unrecognized permission names are ignored without granting additional access. Dropping an unrecognized restriction would permit unauthorized actions. Current versions do not write any feature names to required_features.

Exofind is built by Level Four AB and is available under the Apache License 2.0.