Skip to main content

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:

  1. 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).
  2. Forwards that token to JupyterHub's Hub API in the oauth_access_token field 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:

VariableWhat it containsWhy
OPENBAO_TOKENThe vault token from step 3Gives the notebook pre-authenticated access to OpenBao
OPENBAO_USER_SUBThe user's Keycloak sub identifierScopes KV paths to kv/data/users/<sub>/
OPENBAO_ADDRhttp://openbao.openbao.svc.cluster.local:8200Cluster-internal address of OpenBao
OPENBAO_MOUNT_PATHkvThe KV v2 mount name in OpenBao
SECRET_BACKENDopenbaoTells 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​

WhatWhere
BFF token exchangebff-api/src/middlewares/auth.js — exchangeTokenForBackendMiddleware
BFF spawn requestbff-api/src/routes/jupyterhubRoute.js — POST /jupyterhub/start-server
Spawn hookplatform-stacks/stacks/aws/jupyterhub/bundle.yaml — inject_keycloak_tokens in extraConfig
Token exchange helperssame file — _exchange_token_for_audience, _get_openbao_vault_token, _decode_jwt_sub
Notebook secret accesssparqd-containers/utils/secret_utils.py — OpenBaoBackend, pre_auth mode

Go Deeper​