theAuth is open-source auth for AI agents and humans. Star the repo on GitHub · Read the docs · Run the quickstart · theauth.dev
Last spring I found one API key in four places. It sat in a cron job, a Slack bot, a code-review agent, and a notebook a teammate had forgotten about. The key belonged to a human. It could read everything that human could read, and it could write most of it too.
Nothing was wrong yet. Then I asked a simple question: which of the four did that delete last Tuesday? I could not answer it. The logs said the human did it.
That is the problem this guide fixes. You will give each AI agent its own identity, its own token, and a short list of things it may do. By the end you will have a runnable script that creates agents, denies the wrong calls, logs every decision, and revokes one agent without touching the others.
I use theAuth for this, an open-source TypeScript library (@glinr/theauth). I wrote parts of it, so weigh my opinions accordingly. I will say plainly where it does not fit.
This is guide 5 of 8 in the theAuth guides. It stands on its own, so you can start right here. Nothing comes before it. It is the base for guides 6 to 8.
| Guide | Title | Read it when |
|---|---|---|
| 1 | Add login to an existing Next.js app | You have an app with no auth yet |
| 2 | Passwordless login: passkeys, links, OTP | You want to drop passwords or add 2FA |
| 3 | Multi-tenant SaaS auth: orgs, RBAC, SSO, SCIM | You sell to teams and companies |
| 4 | Migrate from Auth0 or Clerk | You already run another provider |
| 5 | Give every AI agent its own identity (this guide) | You run AI agents and need to start somewhere |
| 6 | Cap agent spend and require human approval | Your agents spend money or act on risky things |
| 7 | Secure an MCP server for production | You expose tools over MCP |
| 8 | Build an audit trail for AI agent actions | Someone will ask what your agents did |
Building for people? Start at guide 1. Building for AI agents? Start at guide 5. Every guide links to the docs page for each concept it touches.
| Step | What you do | Result |
|---|---|---|
| 1 | Install and create an instance | A local SQLite database with agents enabled |
| 2 | Seed an owner | One human row that agents hang off |
| 3 | Create one agent per job | A kv_ token per agent, shown once |
| 4 | Call authorize() before every action |
An allow or deny with an audit ID |
| 5 | Add constraints | Rate limits, argument patterns, time windows |
| 6 | Check tokens in middleware | 401 and 403 at your HTTP edge |
| 7 | Delegate to sub-agents | A narrower scope, with an expiry |
| 8 | Read the audit trail, rotate, revoke | Answers to "which agent did that" |
You need Node 20 or newer, TypeScript, and a package manager. You do not need a running server or an external database. The examples use SQLite on disk so you can open the file afterward.
You also need one honest assumption: the agents in your system call tools you control. theAuth gives you the decision point. Your code must ask it before acting. If an agent can reach a database directly with its own credentials, no permission list in the world will stop it.
A shared human key has three problems, and they stack.
First, the blast radius equals the human's permissions. A code reviewer that only needs to read pull requests inherits write access to everything.
Second, you cannot revoke one agent. Rotate the key and all four processes break at once. You wait, and the exposed key stays live.
Third, the audit trail lies. Every row names the human. Your compliance questions, and your own debugging, hit a wall.
A per-agent identity fixes all three. Each agent holds a token that maps to one row, one owner, and one permission list. The core concepts page describes the loop in one sentence: a user creates agents, agents call authorize() before acting, and every decision lands in the audit trail.
One distinction matters early. An agent is not a user. It has no email, no password, no session, and no OAuth account. It has a bearer token and a permission set. If you catch yourself wiring password reset for an agent, you want a user. The agent identity page draws that line.
mkdir agent-identity-demo && cd agent-identity-demo
npm init -y
npm install @glinr/theauth
npm install -D tsx typescript
Now create demo.ts. I will build it up one piece at a time, and the full file is the sum of the blocks below.
import { createTheAuth } from '@glinr/theauth';
const theauth = await createTheAuth({
database: { provider: 'sqlite', url: 'theauth.db' },
agents: {
enabled: true,
maxPerUser: 10,
auditAll: true,
tokenExpiry: '24h',
},
});
Two settings deserve a note. auditAll records every authorize() call, and it defaults to on. tokenExpiry sets how long a new agent lives when you do not pass expiresAt. A 24 hour default means an agent you forget about stops working by tomorrow. I like that default. A forgotten credential should expire, not linger.
The quickstart walks through the same setup if you want the scaffolded Next.js version instead: https://docs.theauth.dev/quickstart.
Every agent has an owner, and the owner must exist as a row in theauth_users. That column is a foreign key. Skip this step and you will see FOREIGN KEY constraint failed on your first create call. Everyone hits it once.
If you already run theAuth's own auth modules, sign-up creates the row for you. In a scratch script, insert one:
import { users } from '@glinr/theauth';
theauth.db.insert(users).values({
id: 'user-1',
email: 'owner@example.com',
name: 'Owner',
createdAt: new Date(),
updatedAt: new Date(),
}).run();
This insert pattern comes from the quickstart troubleshooting section and works for SQLite. For Postgres, the database docs list the matching call.
Here is the rule I follow: one agent per job, not one agent per person. A nightly pull-request reviewer and a refund bot should never share an identity, even if the same human set up both.
const reviewer = await theauth.agent.create({
ownerId: 'user-1',
name: 'pr-reviewer',
type: 'autonomous',
permissions: [
{ resource: 'mcp:github:*', actions: ['read'] },
],
expiresAt: new Date(Date.now() + 7 * 24 * 3_600_000),
metadata: { purpose: 'nightly PR review' },
});
const refunder = await theauth.agent.create({
ownerId: 'user-1',
name: 'refund-bot',
type: 'autonomous',
permissions: [
{ resource: 'billing:refunds', actions: ['read', 'write'] },
],
});
console.log(reviewer.token); // kv_..., shown once
The token starts with kv_, followed by 32 random bytes in base64url, 46 characters in total. theAuth stores only the SHA-256 hash. A full database dump cannot reveal a live token.
That has a cost. You see the plaintext exactly once, at creation. Put it in your secrets manager before the process exits, or rotate to get a new one. You cannot recover it later.
The type field takes three values.
An autonomous agent runs on its own, with no approval step unless a permission constraint demands one. Cron jobs and unattended assistants fit here. This is the default choice.
A delegated agent receives its permissions from another agent through a delegation chain. Use it for short-lived workers spun up for one task.
A service agent is a long-lived identity for infrastructure, such as an MCP server or an internal microservice. Treat it like a service account.
By default one user can own 10 active agents. The eleventh create call throws a plain Error with the message User <id> has reached the maximum of <n> active agents. The error has no dedicated code, and the REST endpoint reports it as a 500. Raise maxPerUser at initialization if you run a fleet, and catch the error by message, not by code.
An agent identity does nothing until your code asks the question. Put one call in front of every sensitive operation.
const allowed = await theauth.authorize(reviewer.id, {
action: 'read',
resource: 'mcp:github:repos',
});
console.log(allowed.allowed); // true
const denied = await theauth.authorize(reviewer.id, {
action: 'write',
resource: 'mcp:github:repos',
});
console.log(denied.allowed, denied.reason); // false, plus a reason
console.log(denied.auditId); // links to the audit row
The result is { allowed, reason?, auditId }. The reviewer holds only read. A write on the same resource fails, and the denial lands in the audit log with its reason.
Resources are colon-separated strings. You pick the convention. mcp:github:repos, billing:refunds, and db:users:write all work. Consistency matters more than syntax.
The wildcard has one behavior that surprises people. A * segment is not a single-segment wildcard. The matcher stops at the first * and accepts everything after it, including nothing. That means mcp:github:* matches mcp:github:repos, mcp:github:repos:comments, and mcp:github itself.
Put * only in the last position. A pattern like mcp:*:repos looks like "any server, repos only," but it accepts mcp:slack:channels too. Without a wildcard, the pattern and resource must have the same number of segments. The full table lives on the permissions page.
The action list has no fixed set. read, write, execute, and delete are common, but you can define comment or refund if that reads better in your logs. theAuth checks that the requested action appears in the permission's actions array. It does not interpret the word.
A permission can carry constraints, and every one must pass. This is where least privilege gets specific. "May write files" becomes "may write files under /tmp/agent/, 20 times an hour, from the office network."
const filer = await theauth.agent.create({
ownerId: 'user-1',
name: 'file-writer',
type: 'autonomous',
permissions: [
{
resource: 'tool:file_write',
actions: ['execute'],
constraints: {
maxCallsPerHour: 20,
allowedArgPatterns: ['^/(home/agent|tmp)/'],
timeWindow: { start: '09:00', end: '17:00' },
ipAllowlist: ['10.0.0.0/8'],
},
},
],
});
const outside = await theauth.authorize(filer.id, {
action: 'execute',
resource: 'tool:file_write',
arguments: { path: '/etc/passwd' },
ip: '10.1.2.3',
});
console.log(outside.allowed); // false, the path fails the pattern
Each constraint has edges you should know before you rely on it.
maxCallsPerHour counts calls per agent and resource over a rolling hour, in 5-minute buckets. Calls beyond the limit get a reason that starts with Rate limit exceeded. The audit log records them as denied, not rate_limited, even though the type allows both values.
allowedArgPatterns takes regular expression strings. Every string value in arguments must match every pattern. If you list two alternatives as two patterns, nothing will ever pass. Write one pattern with an alternation instead. The check skips non-string values, and it skips the whole request when arguments is missing.
That last detail matters. If your tool wrapper forgets to pass arguments, the constraint silently does nothing. Pass them every time.
The timeWindow compares HH:MM strings against the server's local clock, not UTC. Windows that wrap past midnight, such as 22:00 to 06:00, are not supported. If you need a night shift, you will need to model it differently or enforce it outside theAuth.
ipAllowlist takes exact IPv4 addresses and IPv4 CIDR ranges. IPv6 is not supported. You must pass ip on the authorization request. A request with no ip fails, which is the safe default.
Set requireApproval: true and authorize() always denies with the reason This action requires human approval before execution.
const deployer = await theauth.agent.create({
ownerId: 'user-1',
name: 'deploy-bot',
type: 'autonomous',
permissions: [
{ resource: 'mcp:deploy:staging', actions: ['execute'] },
{
resource: 'mcp:deploy:production',
actions: ['execute'],
constraints: { requireApproval: true },
},
],
});
Read the approval docs carefully, because this one trips people up. theAuth does not ship an approval UI, and authorize() never looks up approvals. After a human approves, authorize() still denies. Your application performs the action itself once theauth.approval.get(id) says approved. The enforcement point is theAuth. The workflow is yours. The approval flows page has the request, approve, and cleanup calls.
Writing permission arrays by hand gets old. theAuth ships named templates as plain Permission[] arrays.
import { permissionTemplates, getPermissionTemplate } from '@glinr/theauth';
const reader = await theauth.agent.create({
ownerId: 'user-1',
name: 'docs-reader',
type: 'autonomous',
permissions: [
...permissionTemplates.mcpBasic,
{ resource: 'tool:custom_tool', actions: ['execute'] },
],
});
const copy = getPermissionTemplate('mcpBasic'); // deep copy, safe to edit
The names are readonly, readwrite, admin, mcpBasic, mcpFull, rateLimitedRead, approvalRequired, and businessHours. Do not reach for admin out of habit. It grants every action on every resource, which is the shared human key all over again.
One gotcha. Spreading from permissionTemplates copies references to the shared objects. If you mutate an entry afterward, you change the template for everyone. Use getPermissionTemplate whenever you plan to edit.
Until now, the agent's ID went straight into authorize(). In a real system the agent calls your API with a bearer token. Use authorizeByToken in middleware.
export async function handle(request: Request): Promise<Response> {
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) return new Response('Unauthorized', { status: 401 });
const result = await theauth.authorizeByToken(token, {
action: 'read',
resource: 'mcp:github:repos',
});
if (!result.allowed) {
return new Response(result.reason ?? 'Forbidden', { status: 403 });
}
return Response.json({ ok: true, auditId: result.auditId });
}
theAuth hashes the token, looks it up, evaluates the permissions, and logs the decision. No JWTs, and no network round trip to a separate auth service. The call hits your own database.
Two behaviors differ between the two entry points, and both bite in production.
The first is expiry. authorizeByToken rejects an expired agent and flips its status to expired. authorize(agentId) only checks status, so it does not notice an elapsed expiresAt until something marks the agent expired. If you call by ID in a background worker, add your own expiry check or route through the token path.
The second is delegation. authorizeByToken checks the agent's own permissions only. authorize(agentId, ...) applies permissions held through delegation. If your sub-agents rely on delegated scopes, call it by ID.
The quickstart also shows a Hono setup on Cloudflare Workers if you want a framework example.
Orchestrators spawn workers. The worker should get less than the parent, never more.
const worker = await theauth.agent.create({
ownerId: 'user-1',
name: 'issue-summarizer',
type: 'delegated',
permissions: [], // starts empty
});
await theauth.delegate({
fromAgent: reviewer.id,
toAgent: worker.id,
permissions: [{ resource: 'mcp:github:issues', actions: ['read'] }],
expiresAt: new Date(Date.now() + 3_600_000), // one hour
maxDepth: 2,
});
const held = await theauth.delegation.getEffectivePermissions(worker.id);
console.log(held);
An agent cannot delegate permissions it does not hold. The attempt fails at delegation time, not at the next authorize(). Each link carries an expiry and a depth counter. Revoking a link cascades to every delegation made onward from it.
The delegation docs cover depth limits and revocation order. For one-off jobs that should vanish on their own, look at ephemeral sessions.
Now the payoff. Remember the Tuesday delete I could not trace? Here is the query that answers it.
const denials = await theauth.audit.query({
agentId: filer.id,
result: 'denied',
limit: 50,
});
for (const row of denials) {
console.log(row.timestamp, row.action, row.resource, row.reason);
}
const csv = await theauth.audit.export({ format: 'csv' });
Each row holds the agent ID, the owner's user ID, the action, the resource, the arguments, the result, the reason, and the evaluation time in milliseconds. Filter by since, until, actions, userId, or result.
Some limits to plan around. Calls rejected before the permission engine runs, such as an unknown or revoked agent, are not logged. Exports cap at the 10,000 most recent entries, so page through audit.query for more. The actions filter applies after limit and offset, so a page can come back shorter than you asked for.
The SDK never edits entries, but tamper evidence is your database's job. The audit trail page is honest about this, and so should you be if an auditor asks. theAuth does not make you compliant by itself.
Two operations finish the lifecycle.
// New token, old one invalid immediately, no overlap window
const rotated = await theauth.agent.rotate(reviewer.id);
console.log(rotated.token);
// Permanent. You cannot undo it.
await theauth.agent.revoke(refunder.id);
Rotation is a single update, so no moment exists where both tokens work. Rotate on a schedule and whenever you suspect exposure. Only active agents can rotate.
Revocation is permanent. To restore access, create a new agent. I think that is the right call. A revoked credential that can come back is a credential an attacker can wait out.
Permission edits through theauth.agent.update take effect immediately for new requests. In-flight requests that already passed authorization are not affected.
Run the whole file and watch the cap, the denials, and the audit rows:
npx tsx demo.ts
authorize() is a simple path. It stops at the first permission that matches the resource and action, then falls back to delegated permissions. It does not expand roles and does not walk relationship graphs.
When you need those, call theauth.policy.evaluate() directly.
const decision = await theauth.policy.evaluate({
subject: { agentId: reviewer.id },
action: 'read',
resource: 'mcp:github:repos',
context: { ip: '203.0.113.42' },
});
console.log(decision.allowed, decision.effect, decision.reason);
console.log(decision.cacheHit, decision.durationMs);
The engine considers every matching permission and combines the results with deny-overrides by default. One failing constraint wins over any number of permits. If nothing matches, the effect is indeterminate with the reason POLICY_NO_MATCHING_PERMISSION, and allowed is false. Treat indeterminate as a deny.
This difference has teeth. Imagine an agent with two permissions that both match mcp:deploy:prod: a broad one, and a narrow one with a time window. authorize() evaluates the first match. evaluate() evaluates both and denies outside the window. The two paths can disagree, and the docs say so directly.
Five limits apply to the engine today, and I would rather you hear them from me.
The decision cache is process-local, with 10,000 entries and a 60 second TTL by default. Nothing calls invalidate() for you when permissions change, so call theauth.policy.invalidate({ agentId }) after a mutation you need to take effect now. In a multi-instance deployment, other processes keep stale entries until the TTL runs out.
Constraints on delegated permissions are not applied inside evaluate(). Only resource and actions carry over. If a delegated scope relies on a time window, enforce it another way.
User-only subjects write no audit row, because audit_logs.agent_id is not nullable.
The engine also has no declarative policy language. Permissions are plain objects. If your security team demands Cedar or Rego, this is not the right fit today. The policy engine page lists every known gap, and the ReBAC page covers relationship checks.
Two features sit on top of identity. You can skip both.
Trust scoring computes a 0 to 100 value from the agent's audit log. A new agent starts at 50. Successful calls add up to 25 points, each denial costs 5, and age adds up to 15. The level maps to untrusted, limited, standard, trusted, or elevated.
const score = await theauth.trust.computeScore(reviewer.id);
console.log(score.score, score.level);
Scores compute on demand. No background scorer runs, and the core hardcodes the thresholds today. A brand-new agent sits at limited, which surprises people. Use the level to route sensitive actions to a human, and treat the number as a hint, not a verdict. Details are on the trust scoring page.
Bearer tokens work fine inside one deployment. When an agent must prove its identity across an organizational boundary, you can give it a W3C DID backed by an Ed25519 key.
const { agentDid, privateKeyJwk } = await theauth.did.generateKey(reviewer.id);
console.log(agentDid.did); // did:key:z6Mk...
const signed = await theauth.did.sign(
reviewer.id,
{ action: 'review', repo: 'acme/api' },
privateKeyJwk,
);
Know the scope before you build on it. theauth.did.verify() looks up the public key in the theAuth database, so it only verifies DIDs created by the same instance. A third party holding only a DID string cannot use it. The private key is also returned once and never stored. Treat it like a bearer token. The DID identity page spells out the lower-level verifyPayload route for outside verifiers.
You skipped Step 2. The owner must exist in theauth_users first.
Check expiresAt. The default expiry is 24 hours from creation. authorizeByToken flips the status to expired, and an expired agent stays expired. Create a new agent or set a longer expiresAt up front.
authorize() evaluates only the first permission whose resource and action match. If its constraints fail, it never tries later matches. Two overlapping permissions on one resource are a trap, so merge them into one.
Your call omitted arguments. The check skips requests with no arguments. Also confirm the value is a string, because the check skips other types.
The window uses the server's local time. A container running in UTC will interpret 09:00 as 09:00 UTC. Pin the timezone or compute against it deliberately.
Expected. authorize() does not consult approvals. Run the action from your own code after the approval status flips.
Look at the cache if you use evaluate(). The cache can serve decisions for up to the TTL, 60 seconds by default, and nothing invalidates them for you. Call invalidate right after the change.
The pages below cover what this guide skipped.
You can, and it beats sharing. But a provider key gives you one scope level, set by the provider. It does not give you per-resource patterns, rate limits per tool, or one audit trail across vendors. Per-agent keys also do not know about your internal tools.
No, but it needs an owner. Agents have no password, session, or OAuth flow. Each one belongs to a user row, and that user must exist before you create the agent.
Rotate it. The old token dies immediately, with no overlap. Because theAuth keeps only the SHA-256 hash, a database leak does not expose live tokens. A leaked plaintext token, from a log line for example, is still usable until you rotate or revoke.
No. Humans signing in still use sessions or OAuth. Agent tokens cover non-interactive callers. For MCP servers that real users connect to, theAuth also ships an OAuth 2.1 server, which is a separate path from agent tokens.
If you run one agent, with one scope, in one process, a scoped provider key and a log line may be enough. This library earns its place once you run 5 or more agents, 2 or more owners, or an auditor asking questions.
Yes. The quickstart shows a D1 binding for Cloudflare Workers, and the database docs list the other providers. Use SQLite for local work and a server database in production.
Which of your agents holds the widest key right now, and what would break if you cut its permissions to one resource? I would like to hear the answer, especially if the answer is "I do not know."
The fastest way in is the quickstart. If this guide saved you time, a star on GitHub helps other developers find the project, and the docs cover every option used above. More about the project lives at theauth.dev.
Next: guide 6, spend caps and human approval. The full list sits in the table at the top of this page.
GDS K S · thegdsks.com · building Glincker · follow on X @thegdsks
Every agent that can act should be able to answer one question: who exactly was that?