ObservabilityLXXIX · Securing GrafanaSecureGrafana
Grafana Session Security
What you'll learn
- Describe the Grafana 11.x session model: signed cookie, server-side session record, absolute and inactivity lifetimes
- Configure [security] cookie_secure, cookie_samesite, strict_transport_security, and the two lifetime settings so the session cannot survive a stolen cookie or a long absence
- Recognise the most common session failure shape: cookie_secure = false on a non-HTTPS-terminating proxy
- Diagnose a flood of re-logins to a rotated secret_key, a misconfigured lifetime, or an HTTPS termination gap
- Rotate the [security] secret_key with a planned window so the blast radius is bounded and the recovery path is rehearsed
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
A Grafana 11.x install runs behind a reverse proxy that terminates TLS. The operator assumes the session cookie is HttpOnly and Secure because HTTPS is enforced at the proxy. The operator checks grafana.ini: cookie_secure is set to false because the deployment is behind a TLS-terminating proxy and the operator copied a sample ini that warned the cookie must be Secure only when Grafana terminates TLS directly. A pen-test scans the cookie; the report shows the cookie is missing the Secure flag because the reverse proxy sets X-Forwarded-Proto but Grafana does not trust it. The pen-test report goes to the CISO with the finding labelled high severity. The cookie was secure in spirit and insecure on the wire.
This lesson is about closing that gap. The session model is the boundary between authentication and the next request. Production Grafana enforces HTTPS, signs the cookie, sets the right flags, and bounds the lifetime.
What it is
A Grafana session is the tuple of (user_id, session_id, signature) that ties a browser to a Grafana user across requests. The session is materialised as a cookie issued by Grafana after a successful login; the cookie carries an opaque session token; the token is HMAC-signed with [security] secret_key; the user record is resolved on every request by looking up the session in the user_session table.
The session has two lifetimes:
- Absolute lifetime — the session is invalidated after this duration regardless of activity. Set by [security] login_maximum_lifetime_duration (default 30d).
- Inactivity lifetime — the session is invalidated after this duration of no activity. Set by [security] login_maximum_inactive_lifetime_duration (default 7d).
The cookie carries four security flags:
- HttpOnly — the cookie is not accessible from JavaScript. Mitigates XSS-driven session theft. Set by Grafana.
- Secure — the cookie is only sent over HTTPS. Mitigates network sniffing. Set by Grafana when [security] cookie_secure = true.
- SameSite — the cookie is only sent on same-origin requests. Mitigates CSRF. Set to lax by default; configurable via cookie_samesite.
- Path — the cookie is scoped to /. Standard.
Browser ----> Grafana /login
|
v
Authenticate (OIDC / LDAP / basic)
|
v
Issue session cookie (HMAC-signed)
|
v
Browser stores cookie; subsequent requests carry it
|
v
Grafana verifies signature, looks up user, resolves role
The fundamental property: every Grafana session is signed by the same secret_key. A leaked secret_key is a compromise of every session, every signed URL, every service-account JWT, and every CSRF token.
Why a sysadmin cares
The session is the boundary between authentication and the next request. A session that is signed weakly, stored insecurely, or allowed to live forever is a session that survives the moment the credential should have stopped working.
The four production failure shapes:
- cookie_secure = false on a non-HTTPS-terminating deploy. The cookie travels over HTTP if any redirect or back-end path returns to plain HTTP. A network observer between the browser and Grafana captures the cookie.
- Absolute lifetime too long. A Grafana with login_maximum_lifetime_duration = 365d has a session that outlives an off-boarded user for almost a year. A stolen cookie is valid for almost a year.
- Inactivity lifetime too long. A Grafana with login_maximum_inactive_lifetime_duration = 30d has a session that survives a forgotten browser on a kiosk for a month. The next visitor to the kiosk is the next user of the session.
- secret_key too short or committed to Git. A 16-byte secret_key is rejected at boot with a clear error; a 32-byte secret_key in a public repo is a compromise of every session that ever existed.
How it works: the cookie, the signature, the record
The session lifecycle is three steps.
1. Login
- Backend authenticates the user (OIDC / LDAP / basic / proxy).
- Grafana creates a row in user_session (session_id, user_id,
created_at, last_seen_at, absolute_expires_at,
relative_expires_at).
- Grafana issues a cookie carrying the session_id, signed with
HMAC-SHA256 keyed by secret_key.
2. Subsequent request
- Browser sends the cookie.
- Grafana verifies the signature against secret_key.
- Grafana looks up the session in user_session.
- Grafana checks the absolute and inactivity lifetimes.
- Grafana updates last_seen_at and resolves the user.
3. Logout (or lifetime expiry)
- Grafana deletes the user_session row.
- The cookie is cleared in the browser.
- Subsequent requests with the same cookie fail signature
verification (because the row is gone, or because the cookie
was rotated).
The signature is HMAC-SHA256. The payload is the session_id; the key is secret_key. A 32-byte secret_key is the minimum; a 64-byte key is the recommended. The signature is verified before the session is looked up; a tampered cookie fails at the signature step without touching the database.
The HTTPS termination matters because Grafana sets the Secure flag only when [security] cookie_secure = true. If the reverse proxy terminates TLS but Grafana does not know that (because cookie_secure = false), the cookie is issued without the Secure flag and is eligible to travel over HTTP if any redirect or back-end path returns to plain HTTP.
How to configure it
Production [security] block
# /etc/grafana/grafana.ini
[security]
# 32 bytes minimum; sourced from the secrets manager.
secret_key = ${GF_SECURITY_SECRET_KEY}
# HTTPS only. cookie_secure = true requires HTTPS at every hop.
cookie_secure = true
cookie_samesite = lax
# HSTS for the Grafana URL. Reinforces HTTPS at the browser layer.
strict_transport_security = true
strict_transport_security_max_age_seconds = 15768000
strict_transport_security_preload = true
# Absolute lifetime: 30 days is the default; shorter is safer.
login_maximum_lifetime_duration = 30d
# Inactivity lifetime: 7 days is the default; shorter is safer.
login_maximum_inactive_lifetime_duration = 7d
# IP address matching for the session. Optional; some operators
# disable this because users connect from changing IPs.
login_ip_address_required = false
# Disable basic auth last; leave enabled for break-glass.
[auth.basic]
enabled = true
nginx with X-Forwarded-Proto
# /etc/nginx/conf.d/grafana.conf
server {
listen 443 ssl http2;
server_name grafana.example.com;
ssl_certificate /etc/nginx/certs/grafana.crt;
ssl_certificate_key /etc/nginx/certs/grafana.key;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Tell Grafana the original scheme was HTTPS so it can set
# the Secure flag on the session cookie.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
}
# /etc/grafana/grafana.ini
[security]
cookie_secure = true
systemd EnvironmentFile for the secret_key
# /etc/grafana/grafana.env
GF_SECURITY_SECRET_KEY=actual-secret-from-vault-min-32-bytes
# /etc/systemd/system/grafana-server.service.d/secrets.conf
[Service]
EnvironmentFile=/etc/grafana/grafana.env
How to validate it
# READ-ONLY: confirm the cookie carries the right flags.
curl -fsS -i -d '{"user":"admin","password":"'"${GF_ADMIN_PASSWORD}"'"}' \
https://grafana.example.com/login | grep -i 'set-cookie'
# Set-Cookie: grafana_session=...; Path=/; HttpOnly; Secure; SameSite=Lax
# READ-ONLY: confirm the session is rejected over HTTP when cookie_secure = true.
curl -fsS -i -d '{"user":"admin","password":"'"${GF_ADMIN_PASSWORD}"'"}' \
http://grafana.example.com/login | head -1
# HTTP/1.1 400 Bad Request
# The cookie is not issued; the browser is forced back to HTTPS.
# READ-ONLY: confirm HSTS is set on every response.
curl -fsS -I https://grafana.example.com/login | grep -i strict-transport-security
# Strict-Transport-Security: max-age=15768000; includeSubDomains; preload
# READ-ONLY: confirm the secret_key length.
grep secret_key /etc/grafana/grafana.ini | awk -F= '{print length($2)}'
# 64 # minimum 32, recommended 64
# READ-ONLY: confirm the session lifetime is bounded.
grep -E 'login_maximum_lifetime_duration|login_maximum_inactive_lifetime_duration' \
/etc/grafana/grafana.ini
# login_maximum_lifetime_duration = 30d
# login_maximum_inactive_lifetime_duration = 7d
# READ-ONLY: enumerate active sessions.
curl -fsS -H "Authorization: Bearer ${GF_SA_TOKEN}" \
'https://grafana.example.com/api/admin/users' | jq '.[] | {login, lastSeenAt}'
# {"login":"alice@example.com","lastSeenAt":"2026-08-14T10:15:00Z"}
# {"login":"bob@example.com", "lastSeenAt":"2026-08-13T22:00:00Z"}
# READ-ONLY: enumerate sessions for a specific user.
curl -fsS -H "Authorization: Bearer ${GF_SA_TOKEN}" \
'https://grafana.example.com/api/admin/users/1/sessions' \
| jq '.[] | {createdAt, lastSeenAt, expiresAt}'
How it can fail
The high-frequency session failure shapes from real Grafana installs.
- cookie_secure = false on a non-HTTPS deploy. The cookie travels over HTTP. A network observer captures the session and the next request to Grafana is authenticated as the original user.
- secret_key rotated without a planned window. Restarting Grafana after a secret_key change invalidates every active session at once. The symptom is a flood of 401s and a spike of re-logins; if the load balancer health check is session-aware the spike can cascade into a capacity incident.
- Reverse proxy strips X-Forwarded-Proto. A misconfigured proxy in front of Grafana drops the original scheme. Grafana issues the cookie without the Secure flag and the browser rejects it on the next round-trip; the symptom is a login loop.
- Absolute lifetime left default at 365d. A Grafana with login_maximum_lifetime_duration = 365d has a session that outlives an off-boarded user for almost a year. A stolen cookie is valid for almost a year. The fix is 30d or shorter.
- secret_key shorter than 32 bytes. A 16-byte secret_key is rejected at boot with secret_key must be at least 32 bytes. The Grafana process refuses to start. The fix is rotate to a longer key.
- HSTS not enabled. A browser that has not previously visited the Grafana URL will negotiate HTTP before redirecting to HTTPS. The first request to the bare hostname is in clear text. The fix is strict_transport_security = true with preload.
How to troubleshoot it
The diagnostic order matters: a 401 flood, a login loop, or a missing Secure flag can all be session failures.
- Pick the boundary. Is the failure at the browser (the cookie is rejected), at the reverse proxy (the cookie is not forwarded), or at Grafana (the signature does not verify)?
- Inspect the cookie. curl -i to the login endpoint and read the Set-Cookie header. The flags are visible; the absence of Secure means cookie_secure = false.
- Check the reverse proxy headers. curl -i -H “X-Forwarded-Proto: https” to confirm Grafana receives the header. A proxy that strips X-Forwarded-Proto produces a cookie without the Secure flag.
- Check the secret_key length. grep secret_key /etc/grafana/grafana.ini | awk. Anything less than 32 bytes is rejected at boot.
- Inspect the user_session table. psql or sqlite3 against the Grafana database; SELECT user_id, last_seen_at, absolute_expires_at FROM user_session ORDER BY last_seen_at DESC. A row with last_seen_at within the inactivity lifetime is active.
- For rotated secret_key: confirm the new value is in the
EnvironmentFile, the systemd unit has been reloaded, and the
process environment reflects it. Run
ps eww $(pgrep -f grafana-server) | tr ' ' '\n' | grep GF_SECURITY.
Security implications
- secret_key is the master credential. Anyone with the key can forge a session cookie for any user. Store in the secrets manager; rotate annually; minimum 32 bytes.
- cookie_secure = true is the production default. A non-HTTPS deploy that sets cookie_secure = false is a deploy that has decided to live with the network-sniffing risk.
- HSTS is the browser-side defence. A bare-hostname request to the Grafana URL is upgraded to HTTPS by the browser before any traffic is sent. Without HSTS, the first request is in clear text.
- SameSite = lax mitigates CSRF. SameSite = strict breaks the OAuth / SAML redirect flow; lax is the safe default.
- Absolute lifetime bounds the stolen-cookie blast radius. A 30-day lifetime means a stolen cookie is useless after 30 days; a 365-day lifetime means almost a year.
- Inactivity lifetime bounds the forgotten-browser blast radius. A 7-day inactivity means a kiosk session is useless after 7 days without activity; a 30-day inactivity means a month.
Performance implications
- Session lookup is per-request. Grafana looks up the user_session row on every authenticated request. With session storage in the database, a slow Postgres inflates every request latency by the time-to-lookup.
- HMAC signing is constant time. The signature verification cost is independent of secret_key length and is negligible against modern CPUs.
- Logout is a delete. DELETE FROM user_session WHERE session_id = ?. A Grafana with millions of sessions pays the delete cost; the index on session_id keeps the lookup bounded.
- Externalising sessions to Redis (an Enterprise feature) removes the database from the hot path. With database session storage, a Postgres failover produces a session-lookup storm for the duration of the failover.
Production guidance
- cookie_secure = true; reverse proxy sends X-Forwarded-Proto.
- HSTS enabled with preload.
- SameSite = lax; not strict (breaks OAuth).
- secret_key length 32 bytes minimum, 64 recommended; stored in the secrets manager.
- Absolute lifetime 30 days; inactivity lifetime 7 days. Shorter is safer.
- secret_key rotation annual; rotation rehearsed on staging; break-glass path tested.
- Active session enumeration on a fixed cadence; sessions older than the inactivity lifetime are a signal.
Verification
You should now be able to answer:
- What are the four flags a Grafana session cookie can carry, and which two are set automatically?
- What is the difference between the absolute lifetime and the inactivity lifetime of a Grafana session?
- Why does cookie_secure = false on a non-HTTPS deploy defeat the purpose of HTTPS at the proxy?
- What is the blast radius of rotating [security] secret_key, and how do you bound it?
- What is the recovery path if a Grafana session cookie is captured by a network observer?
Quiz
Knowledge check · 8 questions
Q1. A Grafana session cookie is signed using which cryptographic construction?
Q2. Setting cookie_secure = true on a Grafana deployed behind a TLS-terminating reverse proxy that strips X-Forwarded-Proto causes a login loop.
Q3. Which of these are required for a production Grafana session?
Q4. The difference between login_maximum_lifetime_duration and login_maximum_inactive_lifetime_duration is:
Q5. Name the HTTP header the reverse proxy must send so Grafana can issue the session cookie with the Secure flag.
Q6. A flood of 401s appears on the load balancer after a secret_key rotation. The most likely cause is:
Q7. A session with login_maximum_lifetime_duration = 365d is acceptable for an internal Grafana behind SSO.
Q8. Which of these are true about HSTS on Grafana 11.x?
Passing score: 75%. Answers are checked in this browser.