A credential vault and identity broker built for the age of autonomous AI. Your agent authenticates once. After that, it can securely act as you β everywhere β without ever holding your raw secrets.
AI agents are getting terrifyingly capable β and they're stuck at the front door.
Every agent that wants to do something real hits the same wall: login. Today the "solutions" are all bad:
- π Paste your password into a prompt. Now it's in a model's context, a log, a trace.
- ποΈ Hand the agent a
.envfull of API keys. One leak and everything's gone. - π€· Re-authenticate on every run. Doesn't scale, breaks automation, drives you insane.
You shouldn't have to choose between capable agents and not getting owned.
You log in once, manually. AgentAuth seals those credentials into a passport. From then on, your agent presents its own key and gets exactly the access you granted β scoped, time-boxed, revocable, and logged β and the raw secret is unsealed only for the instant it's used.
It's a password manager, an OAuth broker, and an audit system β re-imagined as an identity layer for machines.
You (once) AgentAuth Your Agent (forever)
ββββββββββ βββββββββ ββββββββββββββββββββ
deposit creds ββββββββββββΆ π sealed in your passport
mint agent key ββββββββββββΆ π€ bound to passport + scopes βββΆ aa_β¦ (shown once)
β
"what can I use?" βββββββββββββββββ€ discovers (scoped)
π unseal + audit βββββββββββββββββ€ logs into anything
revoke / expire β β
AgentAuth is a vault, so it's engineered like one. Security isn't a feature here β it's the whole product.
A master key (KEK) wraps a unique data key for every passport; every credential
is sealed with AES-256-GCM (random nonce, auth tag, and passport:target AAD
binding). Crack one passport and you've learned nothing about any other. Keys are
versioned and rotatable β old data stays readable while you roll forward.
If the authorization store is unreachable, agents are denied (503) β never
default-allowed. Revocation flips a flag checked on every single request.
There is no "fail open" path. We tested it: pull the database, access stops.
Every issue, deposit, use, revoke, and denial is appended to an HMAC hash-chained
log. Change one row and the chain breaks β detectably. Triggers block
UPDATE/DELETE/TRUNCATE on the table via the normal SQL path (run the runtime
under a least-privilege DB role so it can't disable them β see
SECURITY.md). Secrets never touch the log.
Agents are bound to one passport and gated by scopes (vault:read, vault:use)
and target globs (target:github.com, target:*.internal). A narrowly-scoped
agent can't even enumerate the credentials it isn't allowed to touch.
use hands the unsealed secret back to the agent. Proxy mode never does.
With POST /v1/vault/credentials/:id/proxy, AgentAuth makes the downstream call
server-side, injects the credential into that request, and returns only the
downstream response β with the secret redacted from the body. The agent
chooses the method/path/query/headers/body; the host is pinned to the
credential's target server-side, so the agent can't repoint the request to
exfiltrate the secret.
# Agent presents its own key; AgentAuth calls github.com with the sealed token injected.
curl -s $BASE/v1/vault/credentials/$CRED/proxy -X POST \
-H "authorization: Bearer $APIKEY" -H 'content-type: application/json' \
-d '{"method":"GET","path":"/user","headers":{"accept":"application/vnd.github+json"}}'
# β { "status": 200, "headers": {...}, "body": "{...}" } the raw token is never in the responseThis needs the vault:proxy scope. Injection is configured per credential
at deposit time (injection: bearer Β· basic Β· cookie Β· header Β·
query), so AgentAuth knows exactly where the secret goes. Because proxy mode
returns no secret, you can issue proxy-only agents β grant vault:proxy
without vault:use and the agent can act through credentials it can never read.
Some "logins" aren't an API call β they're a web app behind a cookie, a stored
session, or a form. POST /v1/vault/credentials/:id/browser-login (agent key,
scope vault:use) turns a credential into a concrete browser-login plan:
a small set of instructions ("set these cookies", "fill this login form", "set
this auth header", "seed this localStorage key") that an agent driving a real
browser β Playwright, Puppeteer, computer-use β applies to a page to become
authenticated.
Trust model, stated honestly. The returned plan carries secret material
(the cookie value, auth header, or the password typed into the form). It is the
same trust level as /use β secret material reaches the caller. The strong
"the secret never reaches the agent" guarantee remains proxy mode (HTTP
only). For browser use, the meaningful boundary is the SDK helper: it applies
the plan to a page object and confines the secret to the SDK process's
memory β it returns only a non-secret summary, never handing the values back up
to the agent's reasoning/LLM layer. The server audits mode + target only; the
plan and its secret are never logged.
The non-secret spec lives in metadata.browser on the credential (set at
deposit time / in the admin UI). It describes where the secret goes without
containing it, so it can travel in listing metadata and be edited safely. Four
spec shapes:
mode |
Spec fields | Default for type |
|---|---|---|
cookie |
{ cookies?, url? } β when cookies is omitted the secret is parsed as a name=value; name2=value2 string |
cookie |
header |
{ header?, prefix?, url? } β defaults to Authorization: Bearer <secret> |
api_key Β· oauth_token |
localStorage |
{ origin, key, url? } β sets localStorage[key] = secret on origin |
β |
form |
{ url, fields:[{selector, valueFrom:"secret"|"username"} | {selector, value}], submitSelector?, successUrlIncludes? } |
β |
cookie, api_key, and oauth_token credentials get a sensible default plan
with no spec at all. A password credential has no safe default β it
requires an explicit form spec, else the call returns 422 no_browser_spec.
The response is the matching plan shape: cookie (cookies to set), header
(headers to set), localStorage (items to seed), or form (an ordered
actions list of goto / fill / click).
import { AgentAuthClient } from '@agentauth/sdk';
import { chromium } from 'playwright';
const aa = new AgentAuthClient({ baseUrl, apiKey }); // scope vault:use
const browser = await chromium.launch();
const page = await browser.newPage();
// Fetch the plan, apply it to the page, and get back a NON-secret summary.
// The secret flows only into the browser β never into this return value or a log.
const summary = await aa.browserLogin(page, 'app.example.com');
console.log('logged in via', summary.mode); // e.g. "cookie"
await page.goto('https://app.example.com/dashboard'); // now authenticatedThe SDK surface is browserLogin(page, target) β the safe path, needing only
vault:use β and getBrowserLoginPlan(target) β the liability path
(Python: browser_login / get_browser_login_plan).
getBrowserLoginPlan is the liability path. It returns the plan with the
secret in plaintext to your process. It requires the vault:browser:raw
scope, which is off by default and must be explicitly granted per agent (a
checkbox on the mint-agent page) β without it the call is 403 missing_scope. If
you enable it, treat the return value like a decrypted password: do not log it, do
not pass it to an LLM, do not persist it. AgentAuth cannot enforce this once the
plan leaves the server β the trust boundary moves to your process. Prefer
browserLogin unless you have a concrete reason you can't.
Browser-login is intentionally an SDK feature, not an MCP tool: a stdio MCP bridge has no browser page to apply the plan to, so exposing it there would only surface secret values to the model. MCP agents authenticate with
use_credentialorproxy_request.
Browser host-pinning. A login plan is pinned to the credential's target host: the
optional metadata.browser.allowedDomains allowlist (echoed into every plan) makes the
SDK refuse any navigation, cookie/header injection, form fill, or MFA-code injection on
an off-list host β checked before any secret touches the page, and re-checked at the
moment the code is typed. Revoking the agent mid-flow forces a logout (clears cookies,
navigates to about:blank) so an authenticated session can't outlive the revoked agent.
Real logins hit MFA. AgentAuth turns that from a dead end into a human-in-the-loop handoff: the agent drives the browser to the challenge page, a human approves from their phone, and the one-time code is injected into the page β without the code ever reaching the agent's reasoning layer or any log.
When browserLogin lands on an MFA challenge (detected from the URL, page text, or a
one-time-code input β tunable via metadata.browser.mfa), the summary comes back
{ authenticated: false, mfa: { kind, promptText, challengeId } }. promptText is
non-secret page text (digit runs masked), safe to surface to an LLM. The agent then
calls resolveMfa(page, target, challenge) (Python: resolve_mfa), which:
- opens an approval request (
POST /v1/vault/credentials/:id/mfa/request), - polls until a human approves it from the
/mfaadmin queue β the passport owner, or a per-credentialmetadata.delegateApproverId, - injects the approved code straight into the page's DOM and submits.
The one-time code is sealed at rest under the passport DEK (AAD bound to the
request's immutable row id), single-use (an atomic consume β 410 on a second
fetch), TTL-bounded (5 min), rate-limited per credential+agent, and never logged
or returned to the agent β resolveMfa hands back only { resolved, status, by, at }.
Revoking the agent (or narrowing its target scope) cancels any pending request and zeroes
the sealed code. Every step emits an mfa.* audit event; the code value appears in none
of them.
const summary = await aa.browserLogin(page, 'irs.gov');
if (summary.mfa) {
// A human taps "approve" in the admin queue; the code lands in the page, not here.
const r = await aa.resolveMfa(page, 'irs.gov', summary.mfa);
// r === { resolved: true, status: 'approved', by: 'cpa@firm.com', at: 'β¦' }
}Like browser-login, MFA resolution is an SDK feature, not an MCP tool β it needs a live page to inject the code into. The human approval side is the admin web UI's
/mfaqueue (approve with the code, or deny).
Argon2id password & key hashing Β· constant-time login (no user enumeration) Β·
per-route rate limiting & brute-force / argon2-DoS protection Β· session
revocation (jti denylist) Β· Helmet security headers Β· CORS allowlist Β·
strict body limits Β· Zod validation everywhere Β· request-id correlation Β·
no stack-trace leakage Β· fail-fast secret validation (won't even boot insecure).
cp .env.example .env
# Generate two 32-byte base64 secrets. With openssl (no Node needed):
echo "MASTER_KEY=$(openssl rand -base64 32)" >> .env
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
# β¦or, if you have neither openssl nor host Node, use the Docker image you're
# about to build: docker run --rm node:22-alpine node -e \
# "console.log(require('crypto').randomBytes(32).toString('base64'))"
# β edit .env so each key appears once. Save .env as UTF-8 **without a BOM**.
docker compose up -d --build # app + db + auto-migrate β http://localhost:8080
docker compose exec app node dist/cli/bootstrap.js # prints a ready-to-use AGENT API KEYThat's it: docker compose up brings up the database, applies migrations, and
serves the API; the in-container bootstrap.js mints a principal, a passport, and
an agent key you can hand straight to an agent (via the MCP server, an SDK,
or raw HTTP). On a host with the toolchain installed (pnpm install) you can run
pnpm agentauth:init instead.
Local dev (no Docker for the app)
pnpm install
pnpm db:up # Postgres in Docker (port 5433)
cp .env.example .env # + MASTER_KEY / JWT_SECRET as above
pnpm db:generate && pnpm db:migrate
pnpm dev # http://localhost:8080 β’ docs at /docsInteractive OpenAPI docs are served at /docs. Liveness /healthz,
readiness /readyz, Prometheus metrics /metrics.
BASE=http://localhost:8080
# 1) You β register & log in (once)
curl -s $BASE/v1/principals -H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"correct-horse-battery"}'
TOKEN=$(curl -s $BASE/v1/auth/login -H 'content-type: application/json' \
-d '{"email":"[email protected]","password":"correct-horse-battery"}' | jq -r .token)
# 2) You β open a passport and deposit a credential (the manual login)
PASSPORT=$(curl -s $BASE/v1/passports -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' -d '{"name":"work"}' | jq -r .id)
curl -s $BASE/v1/passports/$PASSPORT/credentials -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"target":"github.com","label":"GH token","type":"api_key","secret":"ghp_xxx"}'
# 3) You β mint a scoped agent key (shown exactly once)
APIKEY=$(curl -s $BASE/v1/agents -H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d "{\"passportId\":\"$PASSPORT\",\"name\":\"ci-bot\",\"scopes\":[\"vault:read\",\"vault:use\",\"target:github.com\"]}" \
| jq -r .apiKey)
# 4) Your agent β logs into anything, no human in the loop
CRED=$(curl -s $BASE/v1/vault/credentials -H "authorization: Bearer $APIKEY" | jq -r '.items[0].id')
curl -s $BASE/v1/vault/credentials/$CRED/use -X POST -H "authorization: Bearer $APIKEY"
# β the sealed secret, unsealed for use, fully audited
# 5) You β changed your mind? Revocation is instant and fail-closed.
curl -s $BASE/v1/agents/<agentId>/revoke -X POST -H "authorization: Bearer $TOKEN"| Area | Endpoint | Who |
|---|---|---|
| Identity | POST /v1/principals, POST /v1/auth/login, POST /v1/auth/logout |
Human |
| Passports | POST/GET /v1/passports |
Human |
| Deposit | POST/GET /v1/passports/:id/credentials |
Human |
| Agents | POST/GET /v1/agents, POST /v1/agents/:id/revoke |
Human |
| Vault | GET /v1/vault/credentials, POST /v1/vault/credentials/:id/use |
Agent |
| Proxy | POST /v1/vault/credentials/:id/proxy (secret-free; vault:proxy) |
Agent |
| Browser | POST /v1/vault/credentials/:id/browser-login (login plan; vault:use, ?raw=true needs vault:browser:raw) |
Agent |
| MFA | POST/GET /v1/vault/credentials/:id/mfa/request[/:reqId] (open + poll; vault:use) |
Agent |
| MFA | GET /v1/mfa, POST /v1/mfa/:id/approve Β· /deny (approval queue) |
Human |
| Audit | GET /v1/audit, GET /v1/audit/verify |
Human |
| Ops | GET /healthz, /readyz, /metrics, /docs |
β |
src/
env.ts fail-fast config (refuses to boot without real secrets)
server.ts Fastify: helmet Β· cors Β· rate-limit Β· swagger Β· error envelope Β· request-id
crypto/
envelope.ts AES-256-GCM envelope encryption Β· key versioning + rotation
secrets.ts argon2id hashing Β· agent key format Β· constant-time helpers
auth/
human.ts session JWTs (jti) + fail-closed revocation
agent.ts fail-closed agent auth Β· scope + target enforcement
lib/
vault.ts deposit / unseal (transient DEK handling, buffer scrubbing)
browser.ts browser-login plan builder Β· host-pinning Β· spec validation
mfa.ts MFA approval handoff Β· sealed single-use codes Β· owner/delegate authz
audit.ts HMAC hash-chained, tamper-evident audit log + verifier
http.ts one error envelope Β· pagination
metrics.ts Prometheus counters
db/schema.ts principals Β· passports Β· credentials Β· agents Β· revoked_sessions Β· audit_events Β· mfa_requests
routes/ principals Β· passports Β· agents Β· vault Β· mfa Β· audit Β· guards
π§ͺ Tested like a vault β a comprehensive unit + integration suite across server, SDKs, and web, all green
- Crypto unit tests β round-trips, tamper rejection, AAD binding, wrong-key failure, format-version & algorithm checks, key-id tagging, rotation.
- Integration tests (Fastify
inject+ ephemeral Postgres) β every route, plus the properties that actually matter: cross-tenant isolation (IDOR), scope & target enforcement, revocation fail-closed, database-down fail-closed, user-enumeration resistance, audit completeness, hash-chain tamper detection, and append-only enforcement.
Built under adversarial review: a fleet of independent agents audited the code (99 findings), every one was fixed, then a second fleet tried to disprove each fix line-by-line. The gaps they found were closed and re-verified.
pnpm test # unit + integration (Vitest + Fastify inject + ephemeral Postgres)
pnpm typecheck # strict TS, no any-escapes
pnpm lint # eslint clean
pnpm build # production build (no source maps)CI runs typecheck β lint β migrate β test β build β Docker image on every push.
docker build -t agentauth .
docker run -p 8080:8080 --env-file .env agentauth # runs as non-root, healthcheckedProduction turns on Postgres TLS by default, locks CORS to your allowlist, enables CSP, and validates that every secret is present and well-formed before accepting a single request.
- MCP server β
packages/mcp-server: drop-in Model Context Protocol server exposinglist_credentials,use_credential, andproxy_request(secret-free proxy mode) tools. Point any MCP-capable agent (Claude Desktop, etc.) at it withAGENTAUTH_API_KEYand your agents get the vault as tools β zero code. - TypeScript SDK β
packages/sdk-ts:new AgentAuthClient({ baseUrl, apiKey })thenawait client.useCredential('github.com')β plusproxy,browserLogin, andresolveMfafor secret-free proxying, browser logins, and MFA handoff. AHumanClientcovers the management API. - Python SDK β
packages/sdk-py: the same surface (AgentAuthClient,HumanClient) overhttpx. - Admin web UI β
web/: a Next.js console for login, passports, credential deposit, agent issuance/revocation, the approvals queue, the MFA approval queue, OAuth connect, and the audit trail. - Runnable examples β
examples/: copy-paste TS + Python agents that fetch and use a credential.
- π KMS-backed keys β
KEY_PROVIDER=kmskeeps the master key in AWS KMS; the in-process key never holds it. Local AES-GCM KEK is the default. - π Zero-downtime key rotation β KEK, JWT signing key, and audit HMAC key are all versioned and rotatable. See the rotation runbook; the re-wrap runs via
pnpm db:rotate(local) /node dist/db/rotate-keys.js(in the shipped image). - πͺͺ OAuth credential capture β authorize a provider in the browser once (PKCE auth-code); AgentAuth seals the tokens and transparently refreshes them when an agent uses the credential. Proactive refresh requires the provider to return
expires_in(so expiry is known); a provider that issues arefresh_tokenbut omitsexpires_inis treated as freshness-unknown and is not proactively refreshed β configure such providers to returnexpires_in, or a server-side expiry surfaces as a downstream401. - π Per-credential policies β max-uses, time windows, and human approval workflows (request β approve β single-use grant) gate sensitive credentials.
- π₯οΈ Browser-login + MFA handoff β turn a credential into a browser-login plan an SDK confines to the page, and resolve MFA challenges through a human approval queue β the one-time code reaches the page, never the agent or a log.
- π mTLS agent identity β agents can authenticate with a client certificate (native or proxy-terminated) instead of a bearer key.
- π TLS termination β native HTTPS (
HTTPS_CERT/HTTPS_KEY) or front it with a proxy.
- β³ Scheduled credential-expiry sweeps & richer approval notifications
- π§ OIDC discovery + more first-class OAuth providers out of the box
- π Built-in dashboards on top of
/metrics
MIT. See SECURITY.md for the security policy and design guarantees.
AgentAuth β because your agents deserve a passport, not your password.