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 keeps the token out of the service’s configuration. The service stores an entry ID and its VTA credential, 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 token retrievals recorded in the VTA audit trail 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 an entry IDVTA Vaultholds the PATvault_release(entryId)sealed JWEPAT decrypted:held in the service's memory, not its config.ServiceGitHub APIapi.github.comGET /user · bearer token200 OKKept out of config:the service fetches the PAT when it needs it.

Prerequisites

  • A running VTA with DIDComm enabled. See Quickstart.
  • Your DID is enrolled with the admin or initiator role in the target context, which pnm vault upsert needs. The service identity you provision later needs only application. 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 encrypts this to the VTA’s DID with DIDComm authcrypt before sending, so the PAT leaves your machine only in encrypted form.

{
  "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. The store is encrypted at rest on an Affinidi-hosted VTA, and on a self-hosted VTA with hardened mode turned on.

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 encrypts the response to the caller’s DID with DIDComm authcrypt, so 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

Using vta-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. It can release vault entries, sign, and read and write memory in its contexts, while context, key, and ACL management stay with the admin role. If the credential is compromised, these stay outside the attacker’s reach:

  • ACL changes.
  • Contexts outside the credential’s scope.
  • Issuing credentials to new identities.

The three commands below deliver the new credential through sealed transfer, with the work split between two roles. One person on one machine can play both:

  • Recipient: the machine where the credential will be used. pnm bootstrap request creates a one-time key pair, keeps the private key in PNM’s local configuration directory, and writes only the public key, a nonce, and an optional label to the request file. The recipient later runs pnm bootstrap open on the same machine, since only that private key can open the bundle.
  • Producer: an administrator whose PNM is connected to the VTA. pnm auth-credential create creates the new DID, adds it to the context’s access control list (ACL), seals its credential to the public key in the request file, and prints a SHA-256 digest of the sealed bundle.

The digest proves that the bundle the recipient opens is the one the producer sealed, because pnm bootstrap open --expect-digest refuses any other bundle. When the roles run on separate machines, move the request file to the producer over a channel you trust, since the producer seals the credential to whichever public key that file carries. The bundle can travel back over any channel, provided the digest reaches the recipient separately over a channel you trust, for example read out over a call.

Here the recipient is the machine that runs your service. The block below first creates the context, if it does not exist yet, and then runs the three commands:

# 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 relays messages without being able to read their contents. Look up the mediator DID advertised by your VTA:

pnm services list

The output shows the mediator DID on the Mediator: line under DIDComm:.

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. On a mediator in Explicit Allow mode, the service’s DID needs a mediator ACL entry to receive the sealed vault response. This feature creates that entry, so without it the 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.52.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 is held in this process's memory,
        // not in the service's configuration.
        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"].as_str().unwrap_or("?"));
    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

echo '{"contextId": "github-service"}' | pnm vault list --filters-file -

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-entry-id>
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-entry-id> | 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 vault scope denied or does not carry the VaultWrite capabilityYour DID is not enrolled in the entry’s contextId, or its role is below initiator.Run pnm acl get <your-did> to check your role and contexts. Create the context first if it is missing: pnm contexts create --id github-service --name "GitHub Service"
pnm vault release fails with vault scope denied or does not carry the FillRelease capabilityYour 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 and the mediator runs in Explicit Allow mode, so it drops replies to your unregistered service DID.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 Mediator: line under DIDComm:.
open_sealed_secret fails with opening a sealed vault secret requires the DIDComm transportThe 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 the PAT out of its configuration and token retrievals recorded 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.