Keep API tokens out of your service config

Store an API token in the VTA vault and release it to services at runtime, using a GitHub PAT as the example.

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:

Serviceholds entry ID onlyVTA Vaultholds the PATvault_release(entryId)sealed JWEPAT decrypted:held in memory for this request only.ServiceGitHub APIapi.github.comGET /user · bearer token200 OKNever in config:the PAT exists only for the duration of this request.

Prerequisites

  • A running VTA with DIDComm enabled. See Quickstart.
  • Your DID is enrolled with the application role (or higher) in the target context. See Grant and revoke access.
  • pnm CLI connected to your VTA (pnm vta info returns 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.

Step 3: Store the entry

pnm vault upsert --entry-file entry.json --secret-file secret.json

The 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:

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.json

credential.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 list

The 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 output

Create the project

cargo new my-service
cd my-service

acl-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 run

Expected output:

Authenticated as: <your-github-login>
  - <your-github-login>/repo-one
  - <your-github-login>/repo-two

Rotate the PAT

When the PAT expires or is revoked in GitHub, update the vault entry without changing the entry ID or redeploying the service:

  1. Create a new secret.json with the replacement PAT.
  2. Add "id": "<your-entry-id>" to entry.json to target the existing entry.
  3. Run the upsert again:
pnm vault upsert --entry-file entry.json --secret-file new-secret.json

The service continues using the same entry ID, with no configuration change required.

Confirm

Test 1: the entry is stored

pnm vault list --context my-app

The 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_abc123
Released 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/user
200

A 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

SymptomLikely causeFix
pnm vault upsert fails with context not foundThe 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 deniedYour 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 messageThe 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 errorThe 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 foundVTA_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 UnsupportedTransportThe 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 setThe 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 UnauthorizedThe 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.

  Retrieve credentials from the VTA vault at runtime: the general pattern covering all supported secret kinds.

  Grant and revoke access: scope release access to specific contexts and DIDs.

  Back up and restore VTA state: how backups handle, and don’t handle, vault secrets.