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.jsexchangeTokenForBackendMiddleware
BFF spawn requestbff-api/src/routes/jupyterhubRoute.jsPOST /jupyterhub/start-server
Spawn hookplatform-stacks/stacks/aws/jupyterhub/bundle.yamlinject_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.pyOpenBaoBackend, pre_auth mode

Go Deeper