---
title: "Add human login plus agent auth to a Hono app"
description: "Add magic-link login for people and scoped tokens for AI agents to one Hono app. A signed-in user mints an agent that calls its own routes. Tested."
canonical: https://theauth.dev/guides/hono-human-and-agent-auth/
lastmod: 2026-10-08
---

Guide

# Add human login plus agent auth to a Hono app

People and agents authenticate differently, and a good app keeps them apart. A person signs in with a link and holds a session cookie. An agent presents a token that names its owner and the few things it may do. You will build both in one file.

Run end to end on 2026-10-08 with @glinr/theauth 0.6.0, Hono 4 and Node 22.

## What you will build

- Magic-link sign-in that sets an HTTP-only session cookie, and a /me route that reads it.
- A POST /agents route where a signed-in person mints an agent limited to reports:* with the read action.
- Agent routes that allow a read and deny a delete, with each decision written to the audit log.

## Before you start

- Node 22 or newer and pnpm, in an empty folder with type: module (see step 1 of the [MCP guide](https://theauth.dev/guides/mcp-server-typescript/)).
- Email delivery is stubbed: the sign-in link prints to your terminal.

## Install and set a session secret

terminal

```sh
pnpm add @glinr/theauth hono @hono/node-server sql.js
pnpm add -D tsx typescript @types/node
export SESSION_SECRET=$(openssl rand -hex 32)
```

The session secret signs the session tokens and must be at least 32 characters.

## Write the app

Save this as src/app.ts.

src/app.ts

```ts
import { serve } from "@hono/node-server";
import { createTheAuth } from "@glinr/theauth";
import { Hono, type Context } from "hono";
import { getCookie, setCookie } from "hono/cookie";

const ORIGIN = "http://localhost:3000";

const theauth = await createTheAuth({
  database: { provider: "sqlite", url: "app.db" },
  baseUrl: ORIGIN,
  auth: { session: { secret: process.env.SESSION_SECRET ?? "" } },
  agents: { enabled: true },
  magicLink: {
    appUrl: ORIGIN,
    callbackPath: "/login/verify",
    // Swap this for your email provider. Logging the link is enough to follow along.
    sendMagicLink: async (email, _token, url) => {
      console.log(`magic link for ${email}: ${url}`);
    },
  },
});

const sessions = theauth.auth.session;
const magic = theauth.magicLink;
if (!sessions || !magic) throw new Error("session and magicLink must be configured");

const bearerOf = (header: string | undefined) => header?.replace(/^Bearer /, "");

const app = new Hono();

// Human sign-in: send the link, then verify it and set the session cookie.
app.post("/login", async (c) => {
  const { email } = await c.req.json<{ email: string }>();
  await magic.sendLink(email.trim().toLowerCase());
  return c.json({ sent: true });
});

app.get("/login/verify", async (c) => {
  const result = await magic.verify(c.req.query("token") ?? "");
  if (!result) return c.json({ error: "invalid or expired link" }, 401);
  setCookie(c, "theauth_session", result.session.token, {
    httpOnly: true,
    sameSite: "Lax",
    secure: false, // set to true behind HTTPS
    path: "/",
    expires: result.session.expiresAt,
  });
  return c.json({ userId: result.user.id, email: result.user.email });
});

// Resolve the signed-in human from the cookie (or a bearer header for scripts).
const currentUserId = async (c: Context): Promise<string | null> => {
  const token = bearerOf(c.req.header("authorization")) ?? getCookie(c, "theauth_session");
  const session = token ? await sessions.validate(token) : null;
  return session?.userId ?? null;
};

app.get("/me", async (c) => {
  const userId = await currentUserId(c);
  return userId ? c.json({ userId }) : c.json({ error: "sign in first" }, 401);
});

// A signed-in human mints a scoped agent. The raw token is shown once.
app.post("/agents", async (c) => {
  const userId = await currentUserId(c);
  if (!userId) return c.json({ error: "sign in first" }, 401);
  const agent = await theauth.agent.create({
    ownerId: userId,
    name: "report-bot",
    type: "autonomous",
    permissions: [{ resource: "reports:*", actions: ["read"] }],
  });
  return c.json({ id: agent.id, token: agent.token });
});

// Agent routes: authorize by the agent's own bearer token, not a session.
app.get("/reports/:id", async (c) => {
  const token = bearerOf(c.req.header("authorization"));
  if (!token) return c.json({ error: "missing token" }, 401);
  const result = await theauth.authorizeByToken(token, {
    action: "read",
    resource: `reports:${c.req.param("id")}`,
  });
  if (!result.allowed) return c.json({ error: "denied", reason: result.reason }, 403);
  return c.json({ report: c.req.param("id"), auditId: result.auditId });
});

app.delete("/reports/:id", async (c) => {
  const token = bearerOf(c.req.header("authorization"));
  if (!token) return c.json({ error: "missing token" }, 401);
  const result = await theauth.authorizeByToken(token, {
    action: "delete",
    resource: `reports:${c.req.param("id")}`,
  });
  return c.json({ allowed: result.allowed, reason: result.reason }, result.allowed ? 200 : 403);
});

serve({ fetch: app.fetch, port: 3000 });
console.log(`listening on ${ORIGIN}`);
```

How the pieces fit:

- auth.session turns on signed, server-side-revocable session tokens. magicLink and agents each create the tables they need, so turn on only what you use.
- We call theauth.magicLink.sendLink and verify from our own routes. That keeps the paths ours and works under any mount prefix.
- verify returns a session token. The route puts it in an HTTP-only cookie, which is what currentUserId reads back.
- authorizeByToken takes the agent's own token, never a session. An agent that presents a human cookie gets nothing, and a human cannot act through an agent token.

## Sign in as a person

terminal

```ts
pnpm tsx src/app.ts
# second terminal:
curl -s -X POST localhost:3000/login -H 'content-type: application/json' -d '{"email":"ada@example.com"}'
```

The server terminal prints magic link for ada@example.com: http://localhost:3000/login/verify?token=.... Open it with a cookie jar so the session sticks:

terminal

```ts
curl -s -c jar "http://localhost:3000/login/verify?token=PASTE_TOKEN"
curl -s -b jar localhost:3000/me
```

The second call returns {"userId":"..."}. Without the cookie it returns 401 sign in first.

## Mint an agent as that person

terminal

```ts
curl -s -b jar -X POST localhost:3000/agents
```

The response is {"id":"...","token":"kv_..."}. The token is shown once, and only a hash is stored. The agent's owner is the signed-in user, so every action it takes traces back to a person.

## Call the agent routes

terminal

```ts
TOKEN=kv_PASTE_HERE
curl -s localhost:3000/reports/q3 -H "authorization: Bearer $TOKEN"
curl -s -X DELETE localhost:3000/reports/q3 -H "authorization: Bearer $TOKEN"
```

The first call returns {"report":"q3","auditId":"..."}. The second returns 403 with No permission grants agent "report-bot" access to "delete" on "reports:q3". Both decisions are in the audit log, linked by auditId.

## Before you ship this

- **Set secure: true on the cookie** once you serve HTTPS, and keep sameSite at Lax or Strict.
- **Protect POST /agents from cross-site requests.** It is cookie-authenticated, so check the Origin header or add a CSRF token before real users touch it.
- **Limit who can mint agents.** The guide lets any signed-in person do it. Cap it with agents.maxPerUser and decide who is allowed.
- **Do not mount the whole adapter by accident.** theAuthHono exposes agent management and audit routes without authentication. Mount it only behind your own guard, as in the [MCP guide](https://theauth.dev/guides/mcp-server-typescript/).
- **Send real email.** Replace the console.log in sendMagicLink with your provider.

## Questions

**Why a cookie for people and a bearer token for agents?**

A cookie suits a browser and is easy to revoke server-side. An agent is not a browser, so it carries a token that names its owner and its permissions, and you can revoke one agent without ending anyone's session.

**Can I use passkeys or OAuth providers instead of magic links?**

Yes. theAuth supports passkeys, OAuth providers, TOTP and more for people. The agent half of this guide does not change.

**Where do I read the audit log?**

Through the audit module, for example filtering by agent id, or the JSON and CSV export. See the audit docs.

## Keep going

[Secure an MCP server](https://theauth.dev/guides/mcp-server-typescript/) [Delegate to sub-agents](https://theauth.dev/use-cases/multi-agent/) [Audit docs](https://docs.theauth.dev/audit) [Get started](https://theauth.dev/get-started/)
