seekrit
Docs/Jupyter notebooks

Jupyter notebooks

A notebook is the easiest place in a codebase to leak a credential. The two usual ways happen without anyone deciding to do anything careless:

  • A token pasted into a cell. A kernel launched from JupyterLab, VS Code, or JupyterHub doesn't inherit the shell where you exported anything, so the obvious fix is to type the credential into the first cell — where it is saved into the .ipynb and committed.
  • A secret printed by a cell. Notebook files store outputs as well as source. print(api_key), a stray secrets on the last line, or a DataFrame built from a connection string all write plaintext into the file that gets pushed and reviewed.

The Python SDK's load() is built for exactly this. One call at the top of the notebook, and the secrets land in os.environ where your libraries already look for them:

pip install seekrit
import seekrit

seekrit.load()

That's the whole integration. Everything downstream reads the environment as usual — psycopg, boto3, openai, sqlalchemy, anything that honors env vars:

import os, psycopg

conn = psycopg.connect(os.environ["DATABASE_URL"])
note

load() needs a service token — a machine credential bound to exactly one app environment. That scope is the notebook's blast radius, so create a token for the environment the analysis should see (a read-only staging, usually) rather than reusing a production one.

Where the token comes from

load() looks in three places, in order:

  1. the token= argument,
  2. $SEEKRIT_TOKEN in the kernel's environment,
  3. an interactive prompt.

The prompt is the point. When no token is configured, load() asks for one through a password field — ipykernel routes getpass to the notebook frontend — so the credential goes into the kernel's memory and never into the notebook file:

seekrit.load()
# seekrit service token (skt_...):  [·······]
# <seekrit: 7 secrets loaded from acme/analytics/staging: API_KEY, DATABASE_URL, …>

To skip the prompt, give the kernel the token in its environment. Launching Jupyter under seekrit run is the tidiest way — the kernel inherits it, and load() finds it without asking:

SEEKRIT_TOKEN=skt_... seekrit run -- jupyter lab

Control the prompt explicitly when you need to:

seekrit.load(prompt=False)   # never ask; raise if no token is configured
seekrit.load(prompt=True)    # require the prompt

For a notebook executed headlessly — papermill, nbconvert --execute, a scheduled job — there is no frontend to prompt, so set SEEKRIT_TOKEN in the environment that runs it. load() says so rather than hanging.

Cell outputs stay clean

load() deliberately does not return your secrets. Its result carries the names it loaded and the scope they came from, and nothing else — so the value that gets saved into the .ipynb when the cell displays it is a summary:

seekrit.load()
# <seekrit: 7 secrets loaded from acme/analytics/staging: API_KEY, DATABASE_URL, …>

There is no result["API_KEY"]; the object has no way to hand back a value. Read them from the environment instead, and keep them out of the last line of a cell:

loaded = seekrit.load()

len(loaded)                  # 7
"DATABASE_URL" in loaded     # True
sorted(loaded)               # ['API_KEY', 'DATABASE_URL', …]
caution

This guards the summary, not your own cells. print(os.environ["API_KEY"]) still writes a secret into the notebook file, and so does an exception traceback that renders a connection string. Clear outputs before committing — jupyter nbconvert --clear-output --inplace notebook.ipynb, or nbstripout as a git filter — and treat a notebook that has ever displayed a secret as needing a rotation.

Re-running the cell

load() refreshes: run the cell again and every name is re-resolved and overwritten, which is what you want after rotating a secret or switching branches. That is the opposite of Client().into_env(), which leaves existing variables alone — calling load() is a statement that seekrit owns these names. To keep what the kernel already has:

seekrit.load(override=False)   # existing os.environ wins; skipped names are listed

Options

Everything the SDK client takes, load() takes too:

seekrit.load(
    token=None,          # default: $SEEKRIT_TOKEN, then prompt
    api_url=None,        # default: $SEEKRIT_API_URL
    overrides={"shared": "dev"},   # pull another env slice of a composed group
    env=None,            # default: os.environ
    override=True,       # resolved secrets win
    prompt="auto",       # "auto" | True | False
    timeout=30.0,
    interpolate=True,    # expand ${OTHER_SECRET} references
)

load() is fail-closed like the rest of the SDK: a bad token, an unreachable API, or a layer that won't decrypt raises instead of loading a partial environment.

Colab, Kaggle, and other hosted kernels

The same call works — the prompt is the mechanism that makes it safe. Hosted notebooks have no shell you control, so $SEEKRIT_TOKEN won't be set and seekrit.load() will ask for the token each session:

!pip install --quiet seekrit
import seekrit; seekrit.load()

Prefer the platform's own secret store for the token itself where one exists (Colab's Secrets panel, Kaggle's Add-ons → Secrets) and pass it through, so a shared notebook prompts its reader rather than carrying your credential:

from google.colab import userdata
seekrit.load(token=userdata.get("SEEKRIT_TOKEN"))

What the notebook can and can't do

The SDK is read-only: it resolves and decrypts. Creating, editing, and rotating secrets stays in the dashboard and CLI, which use a human principal's passphrase-protected key. A notebook holding a service token can read one environment's secrets and nothing else.

Decryption happens in the kernel. The API returns ciphertext plus a data key wrapped to your token's public key, and the SDK unwraps and decrypts locally — the same path as every other seekrit client. See the encryption model.