Overview
The base Kibana deploy puts the UI behind a login form.
That’s the right default… but for a site whose explicit goal is sharing dashboards with the public, we want visitors to land directly on Discover without entering any credentials, while still keeping a basic-auth path for the admin who actually edits things.
This page is about adding that public read-only experience on top of the existing deploy.
Three moving parts:
- A least-privilege role in Elasticsearch that allows reading log indices and using Discover / Dashboard / Visualize, and nothing else.
- An Elasticsearch user (
anonymous) bound to that role. - An anonymous authentication provider in Kibana that auto-logs the visitor in as that user.
A tempting first reaction is to put a guard in nginx: “only allow GET and HEAD on /logs/”.
This unfortunately does not work for Kibana: Kibana issues POST requests for ordinary read operations. Every Discover query is a POST /api/console/proxy with a JSON body. Every Dashboard refresh is a POST against _search. Block POST and the UI breaks immediately.
The correct layer to enforce read-only is the Elasticsearch role: grant read on the data indices and the right Kibana application privileges, withhold write / manage.
The user can then click anywhere, and the ones that try to write fail with 403 Forbidden at the data layer.
Setup
1. Generate the anonymous user’s password
echo "ANONYMOUS=$(openssl rand -hex 24)"Copy the hex value into your password manager, then append it to /opt/observability-logs/.env:
sudo tee -a /opt/observability-logs/.env > /dev/null <<'EOF'
ANONYMOUS_PASSWORD=<paste hex value here>
EOF2. Create the log_viewer role
Read-only, scoped to log indices, with the minimum Kibana application privileges needed to use Discover, Dashboard, and Visualize on the default space.
cd /opt/observability-logs
ELASTIC=$(grep '^ELASTIC_PASSWORD=' .env | cut -d= -f2-)
curl -sX PUT -u "elastic:$ELASTIC" \
-H "Content-Type: application/json" \
http://localhost:9200/_security/role/log_viewer \
-d '{
"cluster": ["monitor"],
"indices": [{
"names": ["filebeat-*", "logstash-*", "logs-*"],
"privileges": ["read", "view_index_metadata"]
}],
"applications": [{
"application": "kibana-.kibana",
"privileges": [
"feature_discover.read",
"feature_dashboard.read",
"feature_visualize.read"
],
"resources": ["space:default"]
}]
}' && echoA few notes on the role definition:
cluster: ["monitor"]is the minimum cluster-level privilege a logged-in Kibana session needs to call_cluster/health,_nodes, etc.- Indices
read+view_index_metadatalets Discover list fields and query documents, but never write, delete, or alter mappings. applications.application: "kibana-.kibana": the registered name of the Kibana application in ES’s security model.resources: ["space:default"]scopes the privileges to the default Kibana space only. If you create more spaces later, the role does not see them.
3. Create the anonymous user
ANON=$(grep '^ANONYMOUS_PASSWORD=' .env | cut -d= -f2-)
curl -sX PUT -u "elastic:$ELASTIC" \
-H "Content-Type: application/json" \
http://localhost:9200/_security/user/anonymous \
-d "{
\"password\": \"$ANON\",
\"roles\": [\"log_viewer\"],
\"full_name\": \"Public anonymous viewer\"
}" && echo
# Verify
curl -s -o /dev/null -w "anonymous auth: HTTP %{http_code}\n" \
-u "anonymous:$ANON" http://localhost:9200/_cluster/health
# anonymous auth: HTTP 200IMPORTANT
The user is literally named
anonymous, but it is still an ES user with a real password. Kibana will log itself in as this user on behalf of the visitor: the visitor never sees the credential. “Anonymous” describes the visitor’s experience, not the ES backend.
4. Kibana auth providers in config/kibana.yml
Create /opt/observability-logs/config/kibana.yml:
# /opt/observability-logs/config/kibana.yml
# Bind on all interfaces. We *must* set this explicitly because the volume mount
# below replaces the official image's default kibana.yml, which had server.host
# set to 0.0.0.0. Without it, Kibana binds to localhost inside the container
# and Docker's port-mapping can't reach it — the container would appear healthy
# (the internal healthcheck on localhost works) but unreachable from outside.
server.host: "0.0.0.0"
# Authentication providers — native YAML structure.
# In Kibana 8.x the XPACK_SECURITY_AUTHC_PROVIDERS_<TYPE>_<NAME>_* environment
# variables are not interpreted correctly for custom nested provider names
# (e.g. "anonymous1", "basic1") — the provider is marked as "not enabled" at
# startup. The canonical solution is to declare providers in kibana.yml.
xpack.security.authc.providers:
anonymous.anonymous1:
order: 0
description: "Public anonymous viewer"
credentials:
username: "anonymous"
password: "${ANONYMOUS_PASSWORD}"
basic.basic1:
order: 1
description: "Login with credentials"sudo mkdir -p /opt/observability-logs/config
sudo tee /opt/observability-logs/config/kibana.yml > /dev/null <<'EOF'
<paste the YAML above here>
EOF
sudo chmod 644 /opt/observability-logs/config/kibana.ymlA few details:
order: 0wins overorder: 1: when a visitor hits/logs/, Kibana tries the anonymous provider first. If that succeeds, the visitor is logged in without seeing any form.basic.basic1stays on as a fallback at order 1. The Kibana login screen offers it as a “Login with credentials” option — used by the admin to log in aselasticfor write access.${ANONYMOUS_PASSWORD}is interpolated by Kibana itself at startup from its container environment. We’ll inject that variable through the compose file in the next step.
WARNING
Whenever you mount a file at
/usr/share/kibana/config/kibana.yml, you replace the official image’s default kibana.yml. The default ships with sensible bindings (server.host: "0.0.0.0", ES host pointing atelasticsearch:9200, etc.) that disappear the moment the mount takes over.Always include at least
server.host: "0.0.0.0"in your custom file, or the container becomes unreachable from outside (and the symptom is misleading: Docker still reports the container as “healthy”, because the in-container healthcheck onlocalhost:5601works).
5. Wire the password into the container, mount the YAML
Two changes to the kibana service in /opt/observability-logs/docker-compose.yml:
- Add
ANONYMOUS_PASSWORDto theenvironment:block so Kibana can interpolate${ANONYMOUS_PASSWORD}inkibana.yml. - Mount
kibana.ymlinto the container.
The relevant lines look like this:
kibana:
# ... unchanged image / depends_on / user / ports / healthcheck ...
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
- ELASTICSEARCH_USERNAME=kibana_system
- ELASTICSEARCH_PASSWORD=${KIBANA_SYSTEM_PASSWORD}
- SERVER_BASEPATH=/logs
- SERVER_REWRITEBASEPATH=true
- SERVER_PUBLICBASEURL=https://farnetiandrea.it/logs
- TELEMETRY_OPTIN=false
- XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY=${KIBANA_ENCRYPTION_KEY}
# NEW — passthrough so kibana.yml can interpolate it
- ANONYMOUS_PASSWORD=${ANONYMOUS_PASSWORD}
volumes:
- /opt/observability-logs/kibana-data:/usr/share/kibana/data
# NEW — mount the custom config
- /opt/observability-logs/config/kibana.yml:/usr/share/kibana/config/kibana.yml:ro6. Restart
A simple restart is not enough, the new env var requires a recreation:
cd /opt/observability-logs
sudo docker compose down
sudo docker compose up -dWait ~90 s, then watch the logs:
sudo docker compose logs -f kibana
# Expect: [INFO ][status] Kibana is now available7. Verify the providers are active
Check from inside the container that the YAML is mounted and the provider is enabled:
sudo docker compose exec kibana cat /usr/share/kibana/config/kibana.ymlThen test from a fresh browser (incognito + hard reload) at your URL (in my case https://farnetiandrea.it/logs):
- Expected: the Welcome to Elastic chooser appears with two options: Public anonymous viewer and Login with credentials. Click the first, and you should land on Discover with no login form.
- If the anonymous option is missing and you only see the basic login form, the provider didn’t load. Check
docker compose logs kibana | grep -i "provider\|authenticator": aLogin attempt for provider with name basic1 is detected, but it isn't enabledline means the YAML mount didn’t take effect (most often because the env vars are still set and overriding it).
8. Confirm read-only enforcement
Worth proving directly that the role does its job:
ANON=$(grep '^ANONYMOUS_PASSWORD=' /opt/observability-logs/.env | cut -d= -f2-)
curl -sX DELETE -u "anonymous:$ANON" \
-o /dev/null -w "delete-index: HTTP %{http_code}\n" \
http://localhost:9200/logs-2026.01.01
# delete-index: HTTP 403ES refuses the write because the log_viewer role does not grant any of the manage / delete / write privileges.
The visitor in the browser hits the same wall the moment they try anything destructive.
What the visitor experiences vs what the admin experiences:
| Path | What happens |
|---|---|
Visitor opens farnetiandrea.it/logs | Lands on the provider chooser, picks “Public anonymous viewer”, goes straight to Discover. |
Admin opens farnetiandrea.it/logs | Same chooser. Picks “Login with credentials”, enters elastic + the password, gets full UI. |
| Visitor tries to save a search | UI surfaces a 403 from ES; the action fails clearly with no data loss. |
| Admin saves a search | Works normally — elastic has superuser and can write to .kibana_*. |
Hardening
The anonymous role only restricts what fields exist in Kibana terms (e.g. only Discover/Dashboard/Visualize, no management).
It does not redact the content of the fields.
If your raw events contain client IPs, JWT tokens, email addresses, or app-internal stack traces, the anonymous user can see them all by clicking into any document in Discover.
Two ways to deal with this on a Basic license (Field-Level Security would solve it elegantly but needs Platinum):
- Redact at ingest: Logstash strips and anonymizes sensitive patterns before events ever reach Elasticsearch. Simple, low-overhead, applies to all consumers uniformly (keep in mind: this also means that even the admin sees anonymous data!).
- Dual-index split: Logstash writes to
logs-internal-*(full data, admin-only) andlogs-public-*(sanitized, anonymous-readable). More flexible but doubles the storage and adds pipeline complexity.
This page walks through option 1, for simplicity.
1. What the redaction does
A filter {} block on each Logstash worker, applied to every event in flight:
gsubregex onevent.originalandmessagereplaces:- IPv4 addresses with their
/24form (1.2.3.42→1.2.3.0). Enough to keep geographic / netblock context, not enough to identify a single visitor. - JWTs (the
eyJ...prefix is unmistakable) →[JWT]. - Email addresses →
[EMAIL]. Bearer <anything>headers →Bearer [TOKEN].- AWS access key IDs (
AKIA<16chars>) →[AWS_KEY].
- IPv4 addresses with their
remove_fielddrops fields that have no value for a public viewer but reveal infrastructure:host.ip,host.mac,host.id,host.architecture,host.containerized,host.os.kernelagent.id,agent.ephemeral_idlog.file.device_id,log.file.inode,log.file.path
2. The filter block
Add this between the input {} and output {} blocks in /opt/observability-logs/pipeline/main.conf on every Logstash worker:
filter {
# 1) Anonymize patterns in the raw event text
if [event][original] {
mutate {
gsub => [
"[event][original]", "\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}\b", "\1.0",
"[event][original]", "eyJ[A-Za-z0-9_=-]+\.[A-Za-z0-9_=-]+\.[A-Za-z0-9_=-]+", "[JWT]",
"[event][original]", "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[EMAIL]",
"[event][original]", "(?i)bearer\s+[A-Za-z0-9._=-]+", "Bearer [TOKEN]",
"[event][original]", "AKIA[0-9A-Z]{16}", "[AWS_KEY]"
]
}
}
if [message] {
mutate {
gsub => [
"message", "\b(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}\b", "\1.0",
"message", "eyJ[A-Za-z0-9_=-]+\.[A-Za-z0-9_=-]+\.[A-Za-z0-9_=-]+", "[JWT]",
"message", "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[EMAIL]",
"message", "(?i)bearer\s+[A-Za-z0-9._=-]+", "Bearer [TOKEN]",
"message", "AKIA[0-9A-Z]{16}", "[AWS_KEY]"
]
}
}
# 2) Drop topology / fleet-revealing fields
mutate {
remove_field => [
"[host][ip]",
"[host][mac]",
"[host][id]",
"[host][architecture]",
"[host][containerized]",
"[host][os][kernel]",
"[agent][ephemeral_id]",
"[agent][id]",
"[log][file][device_id]",
"[log][file][inode]",
"[log][file][path]"
]
}
}3. Restart
Apply on each worker with down + up -d, not restart.
The Beats input on :5044 doesn’t release the socket within the default 10-second grace, so docker compose restart consistently fails with Address already in use on the second startup:
cd /opt/observability-logs
sudo docker compose down
sudo docker compose up -d
# Wait ~60s, then verify
sleep 60
sudo docker compose logs --tail=20 logstash | grep -iE "pipeline|started"You want to see Pipeline started {"pipeline.id"=>"main"} and Starting input listener {:address=>"0.0.0.0:5044"} with no errors.
4. Verify on Elasticsearch
From the VPS:
ELASTIC=$(sudo grep '^ELASTIC_PASSWORD=' /opt/observability-logs/.env | cut -d= -f2-)
# Sneak peek of a recent event
curl -s -u "elastic:$ELASTIC" \
"http://localhost:9200/logs-*/_search?size=1&sort=@timestamp:desc" \
-H "Content-Type: application/json" \
-d '{"query":{"range":{"@timestamp":{"gte":"now-2m"}}}}' \
| python3 -m json.tool | head -50Check that the dropped fields (host.ip, log.file.path…) are absent and that any IPs in event.original end in .0.
5. Wipe pre-redaction history (optional)
The redaction only affects events ingested after the restart.
Older indices still contain the un-redacted versions.
If you want a clean slate:
ELASTIC=$(sudo grep '^ELASTIC_PASSWORD=' /opt/observability-logs/.env | cut -d= -f2-)
curl -sX POST -u "elastic:$ELASTIC" \
"http://localhost:9200/logs-*/_delete_by_query?conflicts=proceed" \
-H "Content-Type: application/json" \
-d '{"query": {"match_all": {}}}' | python3 -m json.toolNew events will repopulate the index pattern within seconds.
6. Trade-offs
| Aspect | This approach (Logstash redact) | Alternative: dual-index split |
|---|---|---|
| Admin sees raw data? | No — same redacted view everyone else gets | Yes — logs-internal-* is admin-only with full data |
| Storage | 1× (just one index per day) | 2× (two indices per day, raw + sanitized) |
| Pipeline complexity | One filter {} block, easy to reason about | Two output {} blocks with conditionals, more moving parts |
| Public viewer trust | Implicit: what’s in ES is already safe | Implicit: role only grants read on logs-public-* |
| Forensics on raw events | Source files on the VPS (tail, journalctl) | logs-internal-* via admin login |