Blockyard integrates with OpenBao (a Vault-compatible secrets manager) to deliver per-user credentials to Shiny apps at runtime. This allows each user to register API keys for external services (AI providers, databases, object storage, etc.) that are securely injected into their sessions.
Prerequisites#
- OIDC authentication must be configured (see Configuration)
- An OpenBao (or HashiCorp Vault) instance, initialized and unsealed
How it works#
- An operator configures OpenBao and defines which services users can enroll credentials for
- Users store their API keys via the web UI or the REST API
- When a user visits a Shiny app, Blockyard injects a scoped OpenBao token into the request so the app can read that user’s credentials
No single compromised component can exfiltrate all user credentials. The server’s OpenBao token is write-scoped — it cannot read user secrets. Only a valid IdP access token (from an active user session) can produce a read-scoped token.
Server configuration#
Add the [openbao] section to your config file. This requires [oidc] to
also be configured.
[openbao]
address = "http://openbao:8200"
role_id = "blockyard-server" # AppRole role identifier
token_ttl = "1h"
jwt_auth_path = "jwt"
[[openbao.services]]
id = "openai"
label = "OpenAI"
[[openbao.services]]
id = "anthropic"
label = "Anthropic"Each [[openbao.services]] entry defines a third-party service whose API
keys users can enroll. The id is used in API paths and as the vault path
segment, label is shown in the web UI. Credentials are stored at
secret/data/users/{sub}/apikeys/{id}.
Authentication#
Blockyard authenticates to OpenBao using AppRole. This replaces the
previous static admin_token approach with a more secure, renewable
credential.
Initial bootstrap#
- Configure the AppRole role in OpenBao (see the
setup-openbao.shscript in the hello-pocketbase example for reference) - Set
role_idin your config (this is a role identifier, not a secret) - Deliver the
secret_idvia environment variable:
BLOCKYARD_OPENBAO_SECRET_ID="your-secret-id" blockyardOn first startup, blockyard uses the secret_id to authenticate, obtains a
scoped token, and persists it to disk. The secret_id is a one-time
bootstrap input — after initial authentication, the server renews its own
token indefinitely.
Steady state#
After bootstrap, the server renews its vault token at half-TTL intervals.
The persisted token is reused across restarts. No secret_id is needed
for routine restarts.
Re-bootstrap#
If the server is down long enough for the persisted token to expire beyond
renewal, re-deliver a fresh secret_id via the environment variable.
Migrating from admin_token#
The admin_token field is deprecated but still accepted. To migrate:
- Set up AppRole in OpenBao (enable the auth method, create a policy and role)
- Replace
admin_tokenwithrole_idin your config - Set
BLOCKYARD_OPENBAO_SECRET_IDfor the first startup - Remove the old
admin_token/BLOCKYARD_OPENBAO_ADMIN_TOKEN
Bootstrapping#
On startup, Blockyard verifies OpenBao is configured correctly:
- KV v2 secrets engine is mounted at
secret/ - JWT auth is configured with your IdP
- Per-user policies restrict each user to reading only their own secrets
(
secret/users/{sub}/*)
The AppRole token must have sufficient permissions for these checks and for writing user secrets.
Enrolling credentials#
Via the web UI#
Log in to Blockyard and navigate to the dashboard. Each configured service has an enrollment form where you can enter your API key.
Via the REST API#
curl -X POST https://blockyard.example.com/api/v1/users/me/credentials/openai \
-H "Authorization: Bearer by_..." \
-H "Content-Type: application/json" \
-d '{"api_key": "sk-proj-abc123..."}'The service name in the URL path must match a configured service id.
Reading credentials in Shiny apps#
Blockyard injects credentials differently depending on the worker mode:
Single-tenant mode (max_sessions_per_worker = 1, default)#
The proxy injects an X-Blockyard-Vault-Token header on each request. The
R process reads it directly:
server <- function(input, output, session) {
# Get the scoped OpenBao token from the request header
vault_token <- session$request$HTTP_X_BLOCKYARD_VAULT_TOKEN
vault_addr <- Sys.getenv("VAULT_ADDR")
# Read your OpenAI API key from OpenBao
resp <- httr2::request(vault_addr) |>
httr2::req_url_path("/v1/secret/data/users", session$request$HTTP_X_SHINY_USER, "openai") |>
httr2::req_headers("X-Vault-Token" = vault_token) |>
httr2::req_perform()
api_key <- httr2::resp_body_json(resp)$data$data$api_key
}Shared containers (max_sessions_per_worker > 1)#
In shared mode, the proxy injects an X-Blockyard-Session-Token header
instead. The app exchanges it for a vault token via a server callback:
server <- function(input, output, session) {
session_token <- session$request$HTTP_X_BLOCKYARD_SESSION_TOKEN
api_url <- Sys.getenv("BLOCKYARD_API_URL")
# Exchange session token for a vault token
resp <- httr2::request(api_url) |>
httr2::req_url_path("/api/v1/credentials/vault") |>
httr2::req_headers("Authorization" = paste("Bearer", session_token)) |>
httr2::req_method("POST") |>
httr2::req_perform()
vault_token <- httr2::resp_body_json(resp)$token
# Then use vault_token to read credentials from OpenBao (same as above)
}Environment variables injected into workers#
All worker containers receive:
| Variable | Value |
|---|---|
SHINY_PORT | The Shiny port (from [docker] shiny_port, default 3838) |
R_LIBS | The restored package library path — typically /blockyard-lib, or /blockyard-lib-store when using the shared package store |
BLOCKYARD_API_URL | The server’s internal API URL (used for runtime package installs and credential exchange) |
When [openbao] is configured, workers also receive:
| Variable | Value |
|---|---|
VAULT_ADDR | The OpenBao server address (from [openbao] address) |
BLOCKYARD_VAULT_SERVICES | JSON map of service IDs to Vault paths (only when [[openbao.services]] are defined) |
Security model#
See the Credential Trust Model in the architecture documentation for a detailed security analysis.
Key properties:
- No single compromised component yields all user credentials
- The server cannot read stored secrets (admin token is write-scoped)
- A compromised server can only intercept credentials for users with active sessions during the window of compromise
- User credentials are encrypted at rest in OpenBao