DEV Community

Deek Roumy
Deek Roumy

Posted on

How to Build an Autonomous AI Agent That Acts on Your Behalf (Without Storing Your Tokens)

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
Enter fullscreen mode Exit fullscreen mode

This works until it doesn't:

  • The token expires, the agent fails silently at 2 AM
  • Your .env gets 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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.'
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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`
  );
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)