OAuth tokens for Grafana alert webhooks
Grafana fires alerts at the worker via Alertmanager-shaped webhooks (POST /api/webhooks/alertmanager). This guide covers the OAuth-token
path — option B from Glad-Labs/poindexter#247.
Why a pre-issued long-TTL JWT, not Grafana’s OAuth contact-point flow
Grafana’s contact-point UI exposes aoauth2 block on newer versions,
but in practice it’s brittle:
- The IdP fields drift across Grafana minor versions (the YAML keys in 10.x don’t match the UI labels in 11.x).
- Grafana’s contact-point HTTP client has no way to refresh through a client_credentials grant — it expects an authorization-code style redirect URI, which a headless alerting system can’t satisfy.
- The IdP-side error messages surface as opaque “401 Unauthorized” in the contact-point test page, with no log on the Grafana side.
alertmanager_webhook_token
during the migration window (Glad-Labs/poindexter#247).
Provision the token
The recommended path is--persist, which stashes the minted JWT
encrypted in app_settings.grafana_webhook_oauth_jwt so
scripts/start-stack.sh can decrypt and pass it through to Grafana
via $GRAFANA_WEBHOOK_TOKEN. No manual paste-into-the-UI step —
restart Grafana and the contact point picks up the new credential.
- First call: registers a new OAuth client named
grafana-alertsinoauth_clients, persists encrypted credentials toapp_settings.grafana_oauth_client_idandapp_settings.grafana_oauth_client_secret. - Every call: mints a fresh JWT bound to that client with the
default scopes
api:read api:writeand the requested TTL. - With
--persist: also stores the minted JWT encrypted inapp_settings.grafana_webhook_oauth_jwt.scripts/start-stack.shdecrypts it on the host (viascripts/_grafana_webhook_token.py) and exports as$GRAFANA_WEBHOOK_TOKENbefore bringing the Grafana container up.infrastructure/grafana/provisioning/alerting/contact-points.ymlsubstitutes that env var intosettings.authorization_credentialson thePoindexter Webhookreceiver.
Why settings instead of secure_settings
Grafana 13.0.1’s file-provisioning loader does NOT run env-var
substitution over secure_settings: — only settings:. Prior
attempts to wire the JWT through secure_settings.authorization_credentials
silently no-op’d (verified during the 2026-05-19 finding #2
investigation; secureFields stayed null after restart with both
camelCase and snake_case keys, and with literal-token values too).
Grafana auto-encrypts plain settings.authorization_credentials at
rest on its side, redacts it on read, and (crucially) DOES run env-var
substitution over it. Our source of truth stays encrypted at rest in
app_settings.grafana_webhook_oauth_jwt via pgcrypto, so the secret
isn’t duplicated unprotected anywhere on disk.
Output looks like:
exp elapses).
Paste into Grafana (legacy — only if you ran mint-grafana-token WITHOUT --persist)
If you minted with --persist, skip this section — the JWT is
already wired through provisioning. The manual paste workflow below
is the fallback for operators who minted with the older flow OR who
want to override the auto-provisioned credential.
Grafana’s UI labels move every few releases. The path below is for Grafana 11.x. If your Grafana looks different, search for “contact points” → your contact point → custom HTTP headers.
- Open Grafana → Alerting → Contact points.
- Click the contact point that points at the worker webhook
(default name:
Poindexter Webhook). - Edit → Optional Webhook settings → Custom HTTP headers.
- Click + Add header.
- Header name:
Authorization - Header value:
Bearer <paste the JWT here>- The literal word
Bearer, then a single space, then the JWT.
- The literal word
- Save contact point.
- Test the contact point (button at top of the edit page). The
worker should respond
200 OKwith a JSON body counting persisted alerts. If you get401 Unauthorized, double-check there’s a single space betweenBearerand the token and no trailing whitespace.
Verify end-to-end
After saving the contact point:OAuth JWT rejected: token expired,
re-run the mint command — your previous token’s exp already elapsed
(unusual on a 90-day TTL, but possible if you provisioned a 60-min
token by accident).
Rotation
Set a calendar reminder for ~80 days after each mint (the default 90-day TTL minus 10 days of buffer). When it fires:exp elapses, so
there’s no traffic gap.
To invalidate ALL JWTs for the Grafana client immediately (e.g. after a
suspected leak), revoke the underlying OAuth client:
Troubleshooting
| Symptom from Grafana | Likely cause |
|---|---|
401 Unauthorized on test | Token typo, missing Bearer prefix, the token’s exp elapsed, or the underlying OAuth client was revoked. Re-mint and re-paste. |
503 Service Unavailable | app_settings.alertmanager_webhook_token is empty. Run poindexter settings set alertmanager_webhook_token "<value>" --secret. |
| Test succeeds, real alerts never fire | Check the Grafana alert rule’s Notifications block — the contact point must be selected. |
verify_alertmanager_token (routes/alertmanager_webhook_routes.py) still
accepts the legacy static alertmanager_webhook_token alongside OAuth JWTs,
so a correctly-set static token also passes. A header that is neither a valid
JWT nor the configured static token gets 401 — or 503 if the static token is
unset and the JWT path doesn’t accept (fail-closed).
See also
docs/operations/secret-rotation.md— full secret-rotation runbook (the Grafana JWT row is one of many).- Glad-Labs/poindexter#241 — OAuth 2.1 migration umbrella, full design rationale.
- Glad-Labs/poindexter#247 — the issue this work closes.