+1 (806) 515-4974

Least-privilege access: roles, API keys, and field-level security

Most clusters we audit have one of two access models: everything runs as elastic, or every app shares one broad service account. Both work until a leaked credential, a runaway dashboard user, or a compliance question shows up. This post covers least-privilege access with roles, API keys, and document- and field-level security. It applies to Elasticsearch with the Elastic Stack security features and, with different syntax in places, to OpenSearch.

The failure modes

  • A shared superuser credential in a Logstash config, so one leaked pipeline file exposes every index.
  • A Kibana user with all on *, who can delete a data stream with one click.
  • An application key that can write to indices it only reads.
  • No way to rotate a credential without a deploy of every consumer.

Step 1: Write roles per workload, not per person

Define one role per workload: an ingest writer, a search reader, a dashboard viewer. Grant the minimum index privileges on named patterns.

PUT _security/role/logs_writer
{
  "cluster": ["monitor"],
  "indices": [
    {
      "names": ["logs-myapp-*"],
      "privileges": ["create_doc", "auto_configure"]
    }
  ]
}

create_doc allows indexing new documents only, with no updates and no deletes. For data streams that is usually all a shipper needs. auto_configure lets it create the data stream on first write. Drop it if your templates and streams are provisioned ahead of time.

A read-only search role:

PUT _security/role/catalog_reader
{
  "indices": [
    {
      "names": ["products-*"],
      "privileges": ["read", "view_index_metadata"]
    }
  ]
}

Grant against aliases where you can. products-* as a pattern breaks quietly when someone names an unrelated index products-tmp-export.

Step 2: Use API keys, with expiry

API keys can be created with a role descriptor that is a subset of the creator's permissions, and they can be invalidated individually.

POST _security/api_key
{
  "name": "myapp-log-shipper-2025q3",
  "expiration": "90d",
  "role_descriptors": {
    "writer": {
      "cluster": ["monitor"],
      "indices": [
        { "names": ["logs-myapp-*"], "privileges": ["create_doc"] }
      ]
    }
  },
  "metadata": { "owner": "platform-team", "ticket": "OPS-1234" }
}

Put the owner in metadata. When you find an unfamiliar key 14 months later, that field is the difference between revoking it and guessing.

Things to check:

  • GET _security/api_key?active_only=true for a list of live keys. Anything without an expiry is a finding.
  • Rotation: create the new key, deploy it, confirm the old key shows no recent use in your audit or proxy logs, then DELETE _security/api_key with the old id.
  • Keys created by a user inherit that user's privileges at most. If the user's role shrinks, the key's effective permissions shrink with it.

Use cross-cluster API keys for cross-cluster search or replication rather than sharing a user's credentials between clusters.

Step 3: Field- and document-level security

Use these when one index serves several audiences. For example, support staff can see orders but not card metadata, and each regional team sees only its region.

PUT _security/role/support_orders
{
  "indices": [
    {
      "names": ["orders"],
      "privileges": ["read"],
      "field_security": {
        "grant": ["*"],
        "except": ["payment.*", "customer.email"]
      },
      "query": "{\"term\": {\"region\": \"emea\"}}"
    }
  ]
}

Trade-offs, stated plainly:

  • Document-level security adds the role query to every search. A cheap term filter costs little. A complex or scripted query on a hot index can measurably raise p99, so measure before and after.
  • Both features are license-gated in Elastic's distribution (check your subscription tier). OpenSearch includes them in its security plugin, configured through roles.yml or the security API instead.
  • If the audiences are very different, separate indices are simpler to reason about than a clever role query.

Step 4: Kibana spaces and feature privileges

Index privileges alone do not control what a user can do in Kibana. Add Kibana feature privileges to the role, scoped to a space:

PUT _security/role/dash_viewer
{
  "indices": [
    { "names": ["logs-*"], "privileges": ["read", "view_index_metadata"] }
  ],
  "applications": [
    {
      "application": "kibana-.kibana",
      "privileges": ["feature_dashboard.read", "feature_discover.read"],
      "resources": ["space:ops"]
    }
  ]
}

Step 5: Audit what you did

Run these checks against the cluster:

  1. GET _security/_authenticate using each credential to confirm the roles it resolves to.
  2. POST _security/user/_has_privileges with the actions you expect to be denied, such as delete_index on a production pattern.
  3. Try the write the key should not be able to do. A 403 is the passing result.
  4. Enable audit logging for access_denied and authentication_failed events at minimum. Full audit logging on a busy cluster produces a lot of volume, so filter by event type and index it with an ILM policy.

OpenSearch notes

  • Roles live in the security plugin: PUT _plugins/_security/api/roles/logs_writer with index_permissions and action groups such as crud or create_index.
  • Tenants play the role Kibana spaces play in OpenSearch Dashboards.
  • API key support differs from Elasticsearch. Check what your OpenSearch version offers; many teams use internal users or roles mapped from SAML/OIDC backend roles instead.

What to do this week

  1. List every credential your pipelines and apps use. Count how many are superusers.
  2. Replace each with a workload-specific role and an expiring API key.
  3. Record an owner for each key in metadata.
  4. Verify denials, not only successes.

If the access model is tangled with an upcoming migration or upgrade, a Cluster & Cost Review can include a security pass. Send us the cluster situation through the contact form.