Every autonomous agent hits the same wall: how does it authenticate?
If you're building an agent that acts on a user's behalf — submitting PRs, posting comments, calling APIs — it needs credentials. The naive solution is to put those credentials in a .env file and move on. But that approach breaks down fast. Tokens expire. Environments get compromised. You want the ability to say "stop" without hunting down every place you've pasted a secret.
I ran into this problem building a bounty-hunting agent. The agent scans GitHub repos for open issues with bounties attached, claims them, writes fixes, and submits PRs — fully autonomously, overnight while I'm asleep. It needs to make authenticated GitHub API calls without me sitting there.
Here's the architecture I landed on using Auth0 Token Vault, and why it's the right pattern for any agent that acts on a user's behalf.
The Problem with Stored Tokens
The typical approach:
# .env
GITHUB_TOKEN=ghp_xxxxxxxxxxxx
This works until it doesn't:
- The token expires, the agent fails silently at 2 AM
- Your
.envgets accidentally committed or your environment is leaked - You want to stop the agent — now you have to rotate a token everywhere it's stored
- You want to limit what the agent can do — too late, the token already has full scope
The Token Vault Approach
Auth0 Token Vault inverts this. Instead of the agent holding your OAuth token, Auth0 holds it. The agent holds a private key. When it needs to act, it signs a short-lived JWT and exchanges it with Auth0 for the real access token.
The flow looks like this:
Agent → signs JWT with private key
JWT → sent to Auth0 /oauth/token
Auth0 → verifies signature + checks your connected account
Auth0 → returns GitHub access token (valid for ~1 hour)
Agent → calls GitHub API → token expires → repeat
Your raw GitHub token never touches the agent's code or filesystem.
Setting Up the Privileged Worker Exchange
This is the specific grant type designed for M2M flows — where the agent acts without the user being present. Auth0 calls it Privileged Worker Token Exchange.
Step 1: Generate an RSA key pair
const { generateKeyPairSync } = require('crypto');
const fs = require('fs');
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
fs.writeFileSync('./private-key.pem', privateKey, { mode: 0o600 }); // Never commit this
fs.writeFileSync('./public-key.pem', publicKey);
Upload public-key.pem to Auth0 during setup. The private key stays on your machine (or in a secret manager, gitignored).
Step 2: Configure the Auth0 client
Your Auth0 application needs to be:
- First-party, confidential client
- OIDC conformant
- Private Key JWT as the authentication method
- Token Vault grant type enabled
When the user logs in to your agent's dashboard, they connect their GitHub account. Auth0 stores those OAuth credentials in Token Vault against their user ID.
Step 3: The token exchange
When the agent needs to make a GitHub call:
const jwt = require('jsonwebtoken');
const axios = require('axios');
const fs = require('fs');
const PRIVATE_KEY = fs.readFileSync('./private-key.pem', 'utf8');
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN;
const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID;
const AUTH0_CREDENTIAL_ID = process.env.AUTH0_CREDENTIAL_ID; // from setup
const USER_ID = 'auth0|...'; // the user the agent is acting on behalf of
async function getGitHubToken() {
// Sign a short-lived JWT (60 second TTL)
const subjectToken = jwt.sign(
{ sub: USER_ID, aud: `https://${AUTH0_DOMAIN}/` },
PRIVATE_KEY,
{
algorithm: 'RS256',
issuer: AUTH0_CLIENT_ID,
expiresIn: 60,
header: {
alg: 'RS256',
typ: 'token-vault-req+jwt' // Required — not standard "JWT"
}
}
);
const response = await axios.post(`https://${AUTH0_DOMAIN}/oauth/token`, {
grant_type: 'urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token',
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
connection: 'github',
client_id: AUTH0_CLIENT_ID,
client_credential_id: AUTH0_CREDENTIAL_ID
});
return response.data.access_token;
}
One thing that burned me: the JWT typ header must be "token-vault-req+jwt", not the standard "JWT". The jsonwebtoken library supports this via the header option in jwt.sign(). Get it wrong and you'll get cryptic validation errors with no helpful message.
Using the Token
With a GitHub token in hand, the agent can use any standard GitHub API client:
const { Octokit } = require('@octokit/rest');
async function claimBounty(owner, repo, issueNumber) {
const token = await getGitHubToken();
const octokit = new Octokit({ auth: token });
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: 'Working on this — will submit a PR shortly.'
});
}
Cache the token in memory with a 60-second safety buffer before expiry, then fetch a fresh one. Don't write it to disk.
Token Caching Pattern
let cachedToken = null;
let tokenExpiresAt = 0;
async function getGitHubTokenCached() {
const now = Date.now();
// 60 second buffer before expiry
if (cachedToken && now < tokenExpiresAt - 60_000) {
return cachedToken;
}
cachedToken = await getGitHubToken();
// Tokens are valid for 3600 seconds by default
tokenExpiresAt = now + (3600 * 1000);
return cachedToken;
}
The User Consent Flow
The agent setup is a one-time flow. The user (you) logs into the agent's dashboard via Auth0 Universal Login, then connects GitHub:
const { auth } = require('express-openid-connect');
// Standard Auth0 express middleware
app.use(auth({
authRequired: false,
auth0Logout: true,
secret: process.env.AUTH0_SESSION_SECRET,
baseURL: process.env.BASE_URL,
clientID: process.env.AUTH0_CLIENT_ID,
issuerBaseURL: `https://${process.env.AUTH0_DOMAIN}`,
}));
// Trigger GitHub connection
app.get('/connect/github', requiresAuth(), (req, res) => {
// Auth0 handles the OAuth flow and stores tokens in Token Vault
res.redirect(
`https://${process.env.AUTH0_DOMAIN}/authorize?` +
`connection=github&` +
`client_id=${process.env.AUTH0_CLIENT_ID}&` +
`response_type=code&` +
`scope=openid+profile+email&` +
`redirect_uri=${process.env.BASE_URL}/callback`
);
});
After this one-time setup, the agent runs autonomously and Auth0 handles token refresh in the background.
Step-Up Auth for High-Stakes Actions
For any action above a certain value threshold, I added a step-up authentication gate. The agent pauses and sends a notification before proceeding:
const STEP_UP_THRESHOLD = 500; // dollars
async function handleBounty(bounty) {
if (bounty.value > STEP_UP_THRESHOLD) {
await sendTelegramNotification(
`High-value bounty found: $${bounty.value} on ${bounty.repo}\n` +
`Reply /approve or /deny`
);
const approved = await waitForApproval(bounty.id, { timeout: 3600_000 });
if (!approved) {
console.log(`Bounty ${bounty.id} denied or timed out, skipping.`);
return;
}
}
await claimBounty(bounty.owner, bounty.repo, bounty.issueNumber);
}
This pattern — autonomous for small actions, human-in-the-loop above a threshold — is underused in agent systems. It's the right default for anything that has real-world consequences.
What Revocation Actually Looks Like
Here's the part that makes this architecture worth the setup cost: revocation is instant and complete.
To stop the agent from accessing GitHub: go to the Auth0 dashboard → the user's connected accounts → disconnect GitHub. Done. No token rotation, no hunting through environments, no waiting for a cache to expire.
The agent calls getGitHubToken(), Auth0 finds no connected GitHub account for that user, returns an error, and the agent logs the failure. Nothing leaks. Nothing lingers.
Contrast this with the .env approach: to stop an agent using a stored token, you have to rotate the GitHub token (invalidating it everywhere it's used), then update every environment that had the old token. During that window, the agent can still make calls.
The Full Security Model
| Property | Stored Token | Token Vault |
|---|---|---|
| Raw token in agent code | ✅ yes | ❌ no |
| Revocation | Rotate GitHub token | Disconnect connection |
| Token TTL | Permanent (until rotated) | ~1 hour per exchange |
| Scope control | At token creation | At connection consent |
| Compromise blast radius | Full GitHub access | Stolen JWT useless after 60s |
Where This Pattern Goes Next
GitHub is the obvious starting point, but the same architecture works for any OAuth service: Google (Drive, Calendar), Slack, Linear, Jira. Your agent can act across multiple services, with the user having explicit per-service control over what the agent can access.
The Model Context Protocol (MCP) is the natural next layer here — building a Token Vault MCP server that any AI agent framework can call to get external service tokens. That would make this whole pattern available to Claude, GPT-4, Gemini, or any local model without reinventing the auth layer every time.
The core lesson from building this: the hardest part of autonomous agents isn't the AI — it's authorization. The AI part is mostly solved. Getting the identity layer right (who can act, on whose behalf, with what permissions, revokable how) is where agents actually fail in production.
Token Vault gives you the right primitives to do this correctly. Start here.
Full code on GitHub: github.com/DeekRoumy/auth0-pip-agent
Top comments (0)