JupyterHub Spawner: OpenBao Secret Authentication
When a user opens a notebook, they need to be able to read and write their own secrets (API keys, credentials, etc.) stored in OpenBao. This page explains how that access is set up automatically — without the notebook ever having to log in to anything.
For how secrets are stored and managed through the UI, see User Secret Management.
The Problem This Solves
Keycloak access tokens expire in about 5 minutes. A notebook session can run for hours. If the notebook had to re-authenticate with Keycloak every 5 minutes, it would be fragile and annoying for the user.
The solution: do the full authentication chain once, at pod startup, while the user's token is still fresh. The result is a long-lived vault token that gets placed directly into the pod's environment before the notebook even starts. The notebook never talks to Keycloak — it only talks to OpenBao using the pre-injected token.
End-to-End Flow
Step 1 — UI Launches the Pod
When the user clicks Start Server in the Cogrion UI, the frontend calls the BFF at POST /jupyterhub/start-server.
The BFF does two things:
- Exchanges the user's session token with Keycloak to get a JupyterHub-scoped token — a token that proves "this user is allowed to use this JupyterHub". This is called a token exchange (RFC 8693).
- Forwards that token to JupyterHub's Hub API in the
oauth_access_tokenfield of the spawn request body.
The token is passed in the body (not a header) because the Hub stores it as part of user_options — the data that gets handed to the spawn hook in the next step.
Step 2 — Spawn Hook Runs Before the Pod Starts
JupyterHub runs a Python hook called inject_keycloak_tokens before starting the single-user pod. This hook has access to user_options, which is where the oauth_access_token from step 1 landed.
The hook performs a second token exchange using _exchange_token_for_audience — this time asking Keycloak for a token scoped to OpenBao (audience: <workspace-id>-openbao). It uses the hub's own OAuth client credentials to do this (spawner.authenticator.client_id and client_secret).
Why a second exchange? OpenBao won't accept a JupyterHub-scoped token. It needs a token specifically addressed to it (aud=<workspace-id>-openbao).
Step 3 — Hub Logs In to OpenBao
With the OpenBao-scoped token in hand, the hub calls OpenBao's JWT auth endpoint:
POST /v1/auth/jwt/login
{
"jwt": "<openbao-scoped token>",
"role": "secret-writer"
}
OpenBao verifies the token against Keycloak's public keys (via OIDC discovery), checks the audience claim, and — if everything is valid — returns a vault token. This vault token is tied to the secret-writer KV policy, which allows reading and writing secrets under kv/data/users/<sub>/.
The hub also decodes the user's unique identifier (sub claim) from the JWT, which is used to scope the KV path to that specific user.
Step 4 — Environment Variables Are Injected
Before the pod starts, the spawn hook sets these environment variables inside the container:
| Variable | What it contains | Why |
|---|---|---|
OPENBAO_TOKEN | The vault token from step 3 | Gives the notebook pre-authenticated access to OpenBao |
OPENBAO_USER_SUB | The user's Keycloak sub identifier | Scopes KV paths to kv/data/users/<sub>/ |
OPENBAO_ADDR | http://openbao.openbao.svc.cluster.local:8200 | Cluster-internal address of OpenBao |
OPENBAO_MOUNT_PATH | kv | The KV v2 mount name in OpenBao |
SECRET_BACKEND | openbao | Tells secret_utils which backend to use |
By the time the user's notebook code runs, all of this is already in place.
Step 5 — Notebooks Access Secrets
From inside a notebook, accessing secrets requires no setup at all:
from secret_utils import secret_utils
# Read a secret
db_password = secret_utils.get_secret("DB_PASSWORD")
# Write a secret
secret_utils.put_secret("DB_PASSWORD", "new-value")
secret_utils detects OPENBAO_TOKEN on import and uses it directly — no login, no token exchange, no Keycloak. The KV paths (kv/data/users/<OPENBAO_USER_SUB>/) are the same ones the BFF uses when the user manages secrets through the UI, so secrets are shared between both surfaces.
:::info Vault token expiry
The vault token TTL begins at spawn time, not at first use. If the pod runs longer than the configured TTL on the secret-writer role in OpenBao, secret calls will start failing with a 403. For workspaces with long-running notebooks, increase the TTL in the OpenBao role configuration.
:::
Code Locations
| What | Where |
|---|---|
| BFF token exchange | bff-api/src/middlewares/auth.js — exchangeTokenForBackendMiddleware |
| BFF spawn request | bff-api/src/routes/jupyterhubRoute.js — POST /jupyterhub/start-server |
| Spawn hook | platform-stacks/stacks/aws/jupyterhub/bundle.yaml — inject_keycloak_tokens in extraConfig |
| Token exchange helpers | same file — _exchange_token_for_audience, _get_openbao_vault_token, _decode_jwt_sub |
| Notebook secret access | sparqd-containers/utils/secret_utils.py — OpenBaoBackend, pre_auth mode |
Go Deeper
- User Secret Management — how secrets are stored and which paths are used
- Token Exchange and Data Access — how RFC 8693 token exchange works across the platform
- JupyterHub Spawner — CogrionClient Token Refresh — the separate problem of keeping the CogrionClient SDK authenticated during a long notebook session