Keep API tokens out of your service config
By the end of this guide, your service retrieves a GitHub PAT from the VTA vault at runtime instead of holding it in its own configuration.
Services that call the GitHub API need a Personal Access Token, but keeping the token in environment variables, CI secrets, or configuration files creates a long-lived exposure surface. The token can be logged, leaked through config dumps, or become stale without warning.
The VTA vault removes the token from the service entirely: the service holds only an entry ID and requests the PAT at runtime via an authenticated request. Rotating the credential is a single update in the vault, with no service redeploy needed. This guide covers two delivery paths: the pnm CLI for scripts and pipelines, and the vta-sdk Rust crate for long-running services. For what the vault stores and how entries are scoped, see Secrets vault. For other credential kinds and the general release pattern, see Retrieve credentials from the VTA vault at runtime.
Use this guide when:
- Your service or AI agent calls the GitHub API and you cannot put a PAT in its own configuration.
- You want every token retrieval audited and access-controlled by DID and context.
- You want to rotate the PAT without redeploying any service.
Native CI secrets remain the simpler choice for pipeline-only use cases.
This same pattern works for any bearer-token API, Slack, Stripe, PagerDuty, and others: swap the target URL and the entry’s secretKind details. GitHub is the example used below.
Once the token is stored, every release follows the flow below:
Prerequisites
- A running VTA with DIDComm enabled. See Quickstart.
- Your DID is enrolled with the
applicationrole (or higher) in the target context. See Grant and revoke access. pnmCLI connected to your VTA (pnm vta inforeturns without error).- A GitHub Personal Access Token with the scopes your service requires.
- For the Rust SDK section: Rust 1.95 or later. See Install Rust.
Store the PAT
Step 1: Create the entry file
Create entry.json describing the vault entry. The contextId scopes which enrolled DIDs can release this entry, the same isolation model used for signing keys. See Contexts for how context isolation works across a VTA. The targets field binds this entry to the GitHub API origin so the vault can scope releases and audit by target.
{
"contextId": "github-service",
"targets": [
{ "kind": "web-origin", "origin": "https://api.github.com" }
],
"label": "GitHub PAT for github-service",
"secretKind": "bearer-token"
}Replace github-service with your context ID. If the context does not exist yet, create it first:
pnm contexts create --id github-service --name "GitHub Service"Step 2: Create the secret file
Create secret.json with the cleartext PAT. The CLI seals this to the VTA’s DID before sending, so the plaintext never leaves your machine unencrypted.
{
"kind": "bearer-token",
"token": "ghp_xxxxxxxxxxxxxxxxxxxx"
}Replace ghp_xxxxxxxxxxxxxxxxxxxx with your actual PAT.
Do not commit secret.json to source control. Add it to .gitignore and delete it after the upsert completes. Do not embed the working PAT in your service’s own configuration or environment once it’s in the vault. VTA backups currently exclude vault secrets. Keep one separate copy for disaster recovery, in your secrets manager or a password vault, not in the service itself. See Back up and restore VTA state.
Confirm the released token is not echoed in logs or debug output. A client error that includes the request can surface the Authorization header, so log the status and request ID rather than the full request.
Step 3: Store the entry
pnm vault upsert --entry-file entry.json --secret-file secret.jsonThe VTA unseals the secret, validates that it matches secretKind: bearer-token, and stores it encrypted at rest.
Sample output:
Upserted entry:
{
"created": true,
"entry": {
"contextId": "github-service",
"createdAt": "2026-08-12T12:15:25.575417210+00:00",
"createdBy": "did:key:z6MkjLfAT61ZbMfUTytxjp6KnDh6B2ijE5YkQJNuouhgt67k",
"id": "vault_f6dfd09ba9ea41e2bba445ab7367843d",
"label": "GitHub PAT for github-service",
"secretKind": "bearer-token",
"targets": [
{
"kind": "web-origin",
"origin": "https://api.github.com"
}
],
"updatedAt": "2026-08-12T12:15:25.575417210+00:00",
"updatedBy": "did:key:z6MkjLfAT61ZbMfUTytxjp6KnDh6B2ijE5YkQJNuouhgt67k",
"version": 1
}
}Note the id value: the service uses it to release the token at runtime.
Choose how to release it:
- pnm CLI: for scripts, CI pipelines, and ad-hoc use. Continue to Release the token via the pnm CLI.
- Rust SDK: for long-running services that call the GitHub API at runtime. Skip to Release the token from a Rust service.
Release the token via the pnm CLI
Run this command when you need the token. Replace <vault-entry-id> with the id from Step 3’s output. The VTA seals the response to the caller’s DID. Only that caller can decrypt it.
pnm vault release <vault-entry-id>Output:
Released secret (cleartext):
{
"kind": "bearer-token",
"token": "ghp_xxxxxxxxxxxxxxxxxxxx"
}For automation, use the global --json flag to suppress the label line and pipe directly to jq:
TOKEN=$(pnm --json vault release vault_f6dfd09ba9ea41e2bba445ab7367843d | jq -r '.token')Use the token immediately in the same script, for example to list the authenticated user’s repositories:
curl -s -H "Authorization: Bearer $TOKEN" https://api.github.com/user/repos?per_page=5 | jq -r '.[].full_name'Release the token from a Rust service
The pnm CLI is built on the same vta-sdk crate that your service will use. Using the SDK directly gives the same sealed-delivery guarantee without shelling out to pnm: the PAT travels from the VTA to your service inside a didcomm-authcrypt JWE.
This path requires the DIDComm transport. A REST client can call vault_release, but has no key-agreement material to decrypt the sealed response with. connect_auto inherits the same limitation whenever it resolves to REST. For this reason, the example always calls connect_didcomm directly, not from_credential or connect_auto.
Provision a service identity
The service needs its own DID enrolled in the VTA access control list (ACL). Your developer account likely has elevated access to the VTA. The service needs only the application role, scoped to releasing vault entries and calling trust tasks, with context, key, and ACL management left to the admin role. If the credential is compromised, the damage stays scoped to that one role:
- No ACL changes.
- No reach into other contexts.
- No issuing credentials to new identities.
Use the bootstrap flow to generate and securely deliver the credential:
# 0. Create the context if it does not exist yet.
pnm contexts create --id github-service --name "GitHub Service"
# 1. Generate a bootstrap request (stores the X25519 secret locally)
pnm bootstrap request --out request.json
# 2. Create the service DID, register it in the ACL, and seal the credential
# to the bootstrap request. The armored bundle goes to bundle.txt; the
# SHA-256 digest prints to stderr, so it stays visible in your terminal.
# Copy it for step 3.
pnm auth-credential create \
--role application \
--contexts github-service \
--label "my-service" \
--recipient request.json > bundle.txt
# 3. Open the sealed bundle to get the plaintext credential file.
# Copy the SHA-256 digest printed by the previous command.
pnm bootstrap open \
--bundle bundle.txt \
--expect-digest <digest-from-step-2> \
--out credential.jsoncredential.json now contains:
{
"did": "did:key:z6Mk...",
"privateKeyMultibase": "z...",
"vtaDid": "did:webvh:..."
}Store credential.json as a secret in your secrets manager. request.json and bundle.txt are single-use and can be deleted once credential.json is generated; none of the three should be committed to source control.
The SDK routes messages through a DIDComm mediator: a separate relay service, with a DID of its own, that your service and the VTA both connect to. It holds an inbox per DID and routes between them by DID rather than by API key or IP allowlist, and it carries the traffic without being able to read it. Look up the mediator DID advertised by your VTA:
pnm services listThe output shows the mediator DID under the DIDComm row.
Set the credential, mediator DID, and vault entry ID as environment variables. Store the credential in your secrets manager for production; the mediator DID and entry ID are not secret:
export VTA_CREDENTIAL=$(cat credential.json)
export VTA_MEDIATOR_DID=did:peer:2... # from pnm services list
export VTA_VAULT_ENTRY_ID=vault_f6dfd0... # the `id` from Step 3's upsert outputconnect_didcomm uses the private key in both directions. Every outbound message is encrypted and signed with the sender’s Ed25519 key (authcrypt), so the VTA can verify the caller’s DID against the ACL. The vault release response is a JWE sealed to the caller’s X25519 key-agreement key, derived from the same Ed25519 seed. open_sealed_secret decrypts it. Only the calling DID can decrypt that response, so anything relaying it in transit, including the mediator, cannot read it.
Create the project
cargo new my-service
cd my-serviceacl-setup registers your service DID with the mediator’s ACL right after connecting. Without it, the mediator silently drops the sealed vault response and every release call times out.
Replace the generated Cargo.toml and src/main.rs with the following:
[package]
name = "my-service"
version = "0.1.0"
edition = "2024"
[dependencies]
vta-sdk = { version = "0.34.0", features = ["session", "acl-setup"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }use serde_json::json;
use std::env;
use vta_sdk::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cred_json = env::var("VTA_CREDENTIAL").map_err(|_| "VTA_CREDENTIAL not set")?;
let cred: CredentialBundle = serde_json::from_str(&cred_json)?;
let vta_mediator = env::var("VTA_MEDIATOR_DID").map_err(|_| "VTA_MEDIATOR_DID not set")?;
let entry_id = env::var("VTA_VAULT_ENTRY_ID").map_err(|_| "VTA_VAULT_ENTRY_ID not set")?;
let client = VtaClient::connect_didcomm(
&cred.did,
&cred.private_key_multibase,
&cred.vta_did,
&vta_mediator,
None,
)
.await?;
// Run all work inside a block so shutdown() is always called,
// even when an early ? returns an error.
let result: Result<_, Box<dyn std::error::Error + Send + Sync>> = async {
// Release the vault entry.
let response = client.vault_release(json!({ "entryId": entry_id })).await?;
// Decrypt the sealed response to get the cleartext VaultSecret.
let jwe = response["sealedSecret"]["jwe"]
.as_str()
.ok_or("missing sealedSecret.jwe")?;
let secret = client.open_sealed_secret(jwe).await?;
let token = secret["token"].as_str().ok_or("missing token")?.to_string();
// Call the GitHub API. The PAT exists only in this process's memory,
// for the duration of this request.
let github = reqwest::Client::builder()
.user_agent(concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION")
))
.build()?;
let auth_header = format!("Bearer {token}");
let user = github
.get("https://api.github.com/user")
.header("Authorization", &auth_header)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?;
// A second call on the same token, to show it's usable for real work,
// not just an identity check.
let repos = github
.get("https://api.github.com/user/repos?per_page=5")
.header("Authorization", &auth_header)
.send()
.await?
.error_for_status()?
.json::<Vec<serde_json::Value>>()
.await?;
Ok((user, repos))
}
.await;
// shutdown() must be called to close the mediator websocket cleanly.
// Placing it here (after the async block, before result?) ensures it
// runs whether the block succeeded or failed.
client.shutdown().await;
let (user, repos) = result?;
println!("Authenticated as: {}", user["login"]);
for repo in &repos {
println!(" - {}", repo["full_name"].as_str().unwrap_or("?"));
}
Ok(())
}Run the service
cargo runExpected output:
Authenticated as: <your-github-login>
- <your-github-login>/repo-one
- <your-github-login>/repo-twoRotate the PAT
When the PAT expires or is revoked in GitHub, update the vault entry without changing the entry ID or redeploying the service:
- Create a new
secret.jsonwith the replacement PAT. - Add
"id": "<your-entry-id>"toentry.jsonto target the existing entry. - Run the upsert again:
pnm vault upsert --entry-file entry.json --secret-file new-secret.jsonThe service continues using the same entry ID, with no configuration change required.
Confirm
Test 1: the entry is stored
pnm vault list --context my-appThe entry created in “Store the PAT” appears, with the id your service will reference.
Test 2: releasing the entry returns the stored token
pnm vault release vault_abc123Released secret (cleartext):
{
"kind": "bearer-token",
"token": "<your-token>"
}Test 3: the released token authenticates against GitHub
This is the test that matters: it proves the round trip works end to end, not just that the vault returned a string.
TOKEN=$(pnm --json vault release vault_abc123 | jq -r '.token')
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $TOKEN" \
-H "User-Agent: vta-confirm" \
https://api.github.com/user200A 401 means the PAT itself is expired or revoked at GitHub, rather than anything being wrong with the vault. Re-issue the PAT and update the entry using Rotate the PAT.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
pnm vault upsert fails with context not found | The contextId in entry.json does not exist on the VTA. | Create the context first: pnm contexts create --id github-service |
pnm vault release returns access denied | Your DID is not enrolled in the context with application role or higher. | Run pnm acl list --context github-service to confirm your DID; re-enrol if missing. |
vault_release call times out with no error message | The acl-setup feature was removed from Cargo.toml, so the mediator never registered your service DID and silently drops the reply. | Confirm features = ["session", "acl-setup"] is present in Cargo.toml. |
open_sealed_secret returns a decryption error | The private key inside VTA_CREDENTIAL does not match the DID that received the sealed response. | Re-run the bootstrap flow to generate a matching credential. |
connect_didcomm fails with DID not found | VTA_MEDIATOR_DID is incorrect or refers to a different VTA. | Run pnm services list and copy the DID from the DIDComm row. |
open_sealed_secret or vault_release returns UnsupportedTransport | The client connected over REST, or connect_auto resolved to REST. | Use connect_didcomm for this operation; a REST client can call vault_release but cannot decrypt the sealed response. |
VTA_CREDENTIAL not set, VTA_MEDIATOR_DID not set, or VTA_VAULT_ENTRY_ID not set | The variable isn’t set in the current shell. export only persists for that terminal session. | Re-run the export commands from “Provision a service identity” in the same terminal you run cargo run in. |
GitHub returns 401 Unauthorized | The stored PAT has expired or been revoked in GitHub. | Generate a new PAT, then re-upsert: pnm vault upsert --entry-file entry.json --secret-file new-secret.json. |
Next steps
Your service can now retrieve the PAT from the vault at runtime, with no credentials in its own configuration and every retrieval in the VTA audit trail. The same vault pattern applies to any other secret kind the VTA supports. Update secretKind and rotate credentials in the vault without touching the service.
Glad to hear it! Please tell us how we can improve more.
Sorry to hear that. Please tell us how we can improve.
Thank you for sharing your feedback so we can improve your experience.