Secret vaults and per-user tokens
Keep MCP and model credentials out of sandboxes, connect users' SaaS accounts to the gateway's per-user token vault, and source MAQPNA's own secrets and keys from HashiCorp Vault, OpenBao, cloud KMS and cloud secret managers.
An agent should never hold a long-lived credential. In MAQPNA, credentials stay at the gateway: the sandbox gets a short-lived session token, and the gateway adds the real credential when it forwards a call. This page covers the three places secrets come from:
- Upstream credentials for MCP servers and model routes, from Kubernetes Secrets.
- The per-user token vault: users connect their own GitHub, Microsoft 365, Google, Salesforce, Slack or other OAuth accounts once, and the gateway injects their token on calls made on their behalf.
- External vaults and key managers for MAQPNA's own secrets and signing keys: HashiCorp Vault and OpenBao (Transit), AWS KMS, Cloud KMS, Azure Key Vault, and any provider of the Secrets Store CSI driver or External Secrets Operator.
flowchart LR
A["Agent in sandbox<br/>session token only"] --> G["MAQPNA gateway"]
S[("Kubernetes Secret<br/>MCPServer auth")] -->|"bearer, header,<br/>OAuth client, mTLS"| G
V[("token vault<br/>sealed AES-256-GCM")] -->|"user's token<br/>perUser: true"| G
K["KMS / Vault Transit<br/>key never leaves"] -->|"sign, seal"| G
CSI["Secrets Store CSI<br/>or External Secrets"] -->|"files"| G
G -->|"credential added here"| U["MCP server or model"]
U2["User in browser"] -->|"/v1/connect/github"| V
When to use it#
- An MCP server needs an API key, OAuth client or client certificate: use
MCPServer.spec.auth. - A tool must act as the user (their GitHub repos, their mailbox): use the token vault with
perUser: true. - Your security policy says secrets live in Vault or a cloud secret manager, and keys in a KMS or HSM.
Upstream credentials on an MCP server#
MCPServer.spec.auth.type is one of none, bearerSecret, headerSecret, oauthClientCredentials, tokenExchange or mtls. The operator copies the referenced Secret into the gateway-only Secret maqpna-upstream-credentials; it never reaches the sandbox.
apiVersion: maqpna.com/v1alpha1
kind: MCPServer
metadata:
name: tickets
namespace: team-a
spec:
url: https://tickets.example.eu/mcp
auth:
type: headerSecret
headerName: X-Api-Key
secretRef: {name: tickets-api, key: api-key}
See MCP servers and tool pinning and MCPServer reference. Model route keys work the same way; see Connect model providers.
The per-user token vault#
How it works#
A user connects a provider once in the browser. The gateway signs them in with your identity provider (OIDC, PKCE), then runs the provider's OAuth consent (PKCE). The resulting tokens are sealed with AES-256-GCM under a key you hold and stored under the verified user. When a session acting for that user calls an MCP server with auth.perUser: true, the gateway injects the user's access token, refreshing and rotating it as needed. The agent never sees it.
| Endpoint | Purpose |
|---|---|
GET /v1/connect/{provider} |
Start consent in the browser |
GET /v1/connect/login/callback |
Identity provider sign-in callback |
GET /v1/connect/{provider}/callback |
Provider callback; register <resourceBaseURL>/v1/connect/<provider>/callback as the redirect URI |
POST /v1/connect/{provider} |
Connect without a browser with an ID token (Identity Assertion JWT grant, ID-JAG; for example Okta Cross App Access) |
DELETE /v1/connect/{provider} |
Disconnect |
Built-in presets: github, microsoft (with tenant), google, salesforce (with domain), slack and generic (set authorizationURL, tokenURL and optionally revocationURL yourself).
Prerequisites#
- OIDC set up for your users (Keycloak and OIDC), and
delegation.resourceBaseURLset to the gateway's publichttpsURL. - An OAuth app at each provider, with its client ID and secret.
- Verified session users (
principalBinding), because tokens are injected only for verified principals unless you setallowUnverifiedPrincipal: true.
Step 1: Create the key and client secrets#
head -c32 /dev/urandom > kek
kubectl create secret generic maqpna-vault-kek -n maqpna-system --from-file=kek=kek
rm kek
kubectl create secret generic maqpna-delegation -n maqpna-system \
--from-literal=login='<idp-client-secret>' \
--from-literal=github='<github-client-secret>'
Step 2: Set the Helm values#
delegation:
resourceBaseURL: https://maqpna.example.eu
clientSecretsSecret: maqpna-delegation # mounted at /etc/maqpna/delegation-secrets
tokenVault:
enabled: true
keySecret: maqpna-vault-kek # key "kek": 32 random bytes
login:
issuer: https://idp.example.eu/realms/maqpna
clientID: maqpna-connect
clientSecretFile: /etc/maqpna/delegation-secrets/login
providers:
- name: github
preset: github
clientID: Iv1.abc
clientSecretFile: /etc/maqpna/delegation-secrets/github
allowedServers: [api.githubcopilot.com]
allowedServers (host globs) is required. MCPServer objects are written by namespace users, so without it anyone could point a per-user server at their own URL and receive other users' tokens. Per-user servers on other hosts never receive the token. allowedNamespaces can restrict it further. allowAnyServer: true turns the check off and is not recommended.
Add each provider's endpoints to sovereignty.allowedEgressHosts when that list is set.
Step 3: Mark the MCP server per-user#
spec:
url: https://api.githubcopilot.com/mcp/
auth:
type: none # or mtls
perUser: true
provider: github
Step 4: Connect an account#
The user opens https://maqpna.example.eu/v1/connect/github in a browser and approves the consent. A session for a user who has not connected gets consent_required.
Verify#
maqpna accounts list --user [email protected]
With gateway.activity.enabled and delegation.tokenVault.mirror: true, the gateway also mirrors each account as a ConnectedAccount object (status only, no secrets) with phase Connected, ReauthRequired or Revoked:
kubectl get connectedaccounts -n maqpna-system
To revoke a user's account at the provider and delete it:
maqpna accounts revoke [email protected] github
A cluster-wide terminate revocation that matches the user also revokes their tokens, unless keepOnKillSwitch: true. Rotate the sealing key with maqpna keys rotate vault.
HashiCorp Vault, OpenBao and cloud KMS for keys#
Everywhere MAQPNA signs (audit checkpoints, evidence packs, usage reports, licences, identities), it takes a key URI. The private key stays in the key manager:
| URI | Key manager |
|---|---|
kms://vault/<mount>/keys/<name> |
HashiCorp Vault Transit |
kms://openbao/<mount>/keys/<name> |
OpenBao Transit |
kms://aws/<key-id, ARN or alias/name> |
AWS KMS |
kms://gcp/projects/P/locations/L/keyRings/R/cryptoKeys/K/cryptoKeyVersions/V |
Google Cloud KMS |
kms://azure/<vault>/<key>[/<version>] or azurekv://<vault>/<key> |
Azure Key Vault |
pkcs11:... |
HSM (images built with -tags pkcs11 only) |
For Vault and OpenBao the gateway reads VAULT_ADDR, VAULT_NAMESPACE, VAULT_CACERT and VAULT_TOKEN_FILE (or VAULT_TOKEN). Without a token it uses Kubernetes auth with MAQPNA_VAULT_K8S_ROLE (mount MAQPNA_VAULT_K8S_MOUNT, default kubernetes). Set them with gateway.extraEnv.
audit:
checkpointSigning:
enabled: true
keyURI: kms://vault/transit/keys/maqpna-audit
gateway:
extraEnv:
- {name: VAULT_ADDR, value: https://vault.example.eu:8200}
- {name: MAQPNA_VAULT_K8S_ROLE, value: maqpna-gateway}
Key types, Transit policies and permissions are in Cloud identity & keys.
Secrets from a secret manager#
Every secret MAQPNA reads can come from a file, and every secret environment variable has a _FILE twin (for example MAQPNA_ADMIN_TOKEN_FILE). So both common bridges work:
- External Secrets Operator writes ordinary Kubernetes Secrets from Vault KV, AWS Secrets Manager, Google Secret Manager or Azure Key Vault. The chart's Secret references work unchanged.
- Secrets Store CSI driver mounts secrets as files. Enable
gateway.secretsStoreCSI(secretProviderClass,mountPathdefault/mnt/secrets-store) and point*Filesettings or_FILEvariables at it.
Examples for each provider are in Cloud identity & keys.
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
Helm fails: tokenVault.enabled requires delegation.tokenVault.keySecret |
No sealing key | Create the kek Secret (Step 1) |
consent_required on a per-user call |
The user has not connected the provider | Send them to /v1/connect/<provider> |
server_not_allowed_for_provider |
The MCP server's host is not in allowedServers |
Add the host glob to the provider |
principal_unverified |
The session's user is not verified | Set up principalBinding, or (not recommended) allowUnverifiedPrincipal: true |
Account phase ReauthRequired |
The provider refused the refresh token | The user connects again |
MCPServer rejected: perUser requires provider and type none or mtls |
Wrong auth.type with perUser |
Use type: none or mtls and set provider |
| Vault signing fails at start-up | No VAULT_ADDR, or the token lacks Transit sign |
Set the env vars; check the Transit policy |