> Markdown version of https://authpi.com/docs/quickstarts/hono/ — fetch the complete AuthPI docs index at https://authpi.com/llms.txt to discover all available pages.

# Add authentication to a Hono app

Add authentication to your [Hono](https://hono.dev) app with AuthPI — using the AuthPI SDK or any OIDC client library.

## Overview

This guide walks through adding AuthPI authentication to a Hono application. Every code section shows two approaches side by side:

- **@authpi/idp** — the official AuthPI SDK with built-in PKCE, token refresh, and agent management.
- **arctic** — a lightweight, generic OAuth 2.0 client where you wire up endpoints yourself.

Both implement the Authorization Code flow with PKCE (S256).

Hono runs on Cloudflare Workers, Node.js, Bun, and Deno. This guide uses the Cloudflare Workers pattern with typed environment bindings, but the same routes work on any runtime with minor adjustments to how you access environment variables.

### Prerequisites

- An AuthPI account with an **Issuer** configured
- A registered **Client** (Web type) with redirect URI set to `http://localhost:3000/auth/callback`
- Your **Client ID** and **Client Secret** noted
- A test **User** created in your Issuer

## Install

**@authpi/idp:**
```bash
npm install hono @authpi/idp
```

**arctic:**
```bash
npm install hono arctic
```

## Environment variables

Define a `Bindings` type for your environment variables. On Cloudflare Workers, set these as secrets via `wrangler secret put`. On Node.js or Bun, load them from a `.env` file.

```typescript
// src/types.ts
export type Bindings = {
  AUTHPI_ISSUER_URL: string;
  AUTHPI_CLIENT_ID: string;
  AUTHPI_CLIENT_SECRET: string;
  BASE_URL: string;
};
```

```bash
# Example values
AUTHPI_ISSUER_URL=https://idp.authpi.com/iss_your_issuer_id
AUTHPI_CLIENT_ID=cli_your_client_id
AUTHPI_CLIENT_SECRET=cli_secret_your_client_secret
BASE_URL=http://localhost:3000
```

## Configure the client

Create a helper function that builds a client from the environment bindings.

**@authpi/idp:**
```typescript
// src/auth.ts
import { IdpClient } from "@authpi/idp";
import type { Bindings } from "./types";

export function createAuthClient(env: Bindings) {
  return new IdpClient({
    issuerUrl: env.AUTHPI_ISSUER_URL,
    clientId: env.AUTHPI_CLIENT_ID,
    clientSecret: env.AUTHPI_CLIENT_SECRET,
    redirectUri: `${env.BASE_URL}/auth/callback`,
  });
}
```

**arctic:**
```typescript
// src/auth.ts
import * as arctic from "arctic";
import type { Bindings } from "./types";

export function createOAuthClient(env: Bindings) {
  return {
    client: new arctic.OAuth2Client(
      env.AUTHPI_CLIENT_ID,
      env.AUTHPI_CLIENT_SECRET,
      `${env.BASE_URL}/auth/callback`
    ),
    issuerUrl: env.AUTHPI_ISSUER_URL,
  };
}
```

## Login route

Redirect the user to AuthPI's authorization endpoint. Store the PKCE code verifier and state in httpOnly cookies so they survive the redirect.

**@authpi/idp:**
```typescript
// src/routes/login.ts
import { Hono } from "hono";
import { setCookie } from "hono/cookie";
import type { Bindings } from "../types";
import { createAuthClient } from "../auth";

const app = new Hono<{ Bindings: Bindings }>();

app.get("/auth/login", async (c) => {
  const authClient = createAuthClient(c.env);

  const { url, codeVerifier, state } = await authClient.createAuthorizationUrl({
    scopes: ["openid", "profile", "email"],
  });

  setCookie(c, "code_verifier", codeVerifier, {
    path: "/",
    httpOnly: true,
    secure: true,
    sameSite: "Lax",
    maxAge: 600,
  });

  setCookie(c, "oauth_state", state, {
    path: "/",
    httpOnly: true,
    secure: true,
    sameSite: "Lax",
    maxAge: 600,
  });

  return c.redirect(url.toString());
});

export default app;
```

**arctic:**
```typescript
// src/routes/login.ts
import { Hono } from "hono";
import { setCookie } from "hono/cookie";
import * as arctic from "arctic";
import type { Bindings } from "../types";
import { createOAuthClient } from "../auth";

const app = new Hono<{ Bindings: Bindings }>();

app.get("/auth/login", async (c) => {
  const { client, issuerUrl } = createOAuthClient(c.env);

  const state = arctic.generateState();
  const codeVerifier = arctic.generateCodeVerifier();

  const url = client.createAuthorizationURLWithPKCE(
    `${issuerUrl}/authorize`,
    state,
    arctic.CodeChallengeMethod.S256,
    codeVerifier,
    ["openid", "profile", "email"]
  );

  setCookie(c, "code_verifier", codeVerifier, {
    path: "/",
    httpOnly: true,
    secure: true,
    sameSite: "Lax",
    maxAge: 600,
  });

  setCookie(c, "oauth_state", state, {
    path: "/",
    httpOnly: true,
    secure: true,
    sameSite: "Lax",
    maxAge: 600,
  });

  return c.redirect(url.toString());
});

export default app;
```

## Callback route

Exchange the authorization code for tokens, then store them in a session cookie. In production you should encrypt this cookie — here we keep it simple with JSON.

**@authpi/idp:**
```typescript
// src/routes/callback.ts
import { Hono } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
import type { Bindings } from "../types";
import { createAuthClient } from "../auth";

const app = new Hono<{ Bindings: Bindings }>();

app.get("/auth/callback", async (c) => {
  const url = new URL(c.req.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");

  const storedState = getCookie(c, "oauth_state");
  const codeVerifier = getCookie(c, "code_verifier");

  if (!code || !state || !storedState || !codeVerifier || state !== storedState) {
    return c.json({ error: "Invalid callback parameters" }, 400);
  }

  const authClient = createAuthClient(c.env);
  const agent = await authClient.exchangeCode(code, codeVerifier);

  setCookie(c, "session", JSON.stringify(agent.tokens), {
    path: "/",
    httpOnly: true,
    secure: true,
    sameSite: "Lax",
    maxAge: 60 * 60 * 24 * 7, // 7 days
  });

  deleteCookie(c, "code_verifier");
  deleteCookie(c, "oauth_state");

  return c.redirect("/dashboard");
});

export default app;
```

**arctic:**
```typescript
// src/routes/callback.ts
import { Hono } from "hono";
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
import type { Bindings } from "../types";
import { createOAuthClient } from "../auth";

const app = new Hono<{ Bindings: Bindings }>();

app.get("/auth/callback", async (c) => {
  const url = new URL(c.req.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");

  const storedState = getCookie(c, "oauth_state");
  const codeVerifier = getCookie(c, "code_verifier");

  if (!code || !state || !storedState || !codeVerifier || state !== storedState) {
    return c.json({ error: "Invalid callback parameters" }, 400);
  }

  const { client, issuerUrl } = createOAuthClient(c.env);

  const tokens = await client.validateAuthorizationCode(
    `${issuerUrl}/token`,
    code,
    codeVerifier
  );

  const sessionData = {
    accessToken: tokens.accessToken(),
    refreshToken: tokens.refreshToken(),
    idToken: tokens.idToken(),
    expiresAt: tokens.accessTokenExpiresAt().toISOString(),
  };

  setCookie(c, "session", JSON.stringify(sessionData), {
    path: "/",
    httpOnly: true,
    secure: true,
    sameSite: "Lax",
    maxAge: 60 * 60 * 24 * 7, // 7 days
  });

  deleteCookie(c, "code_verifier");
  deleteCookie(c, "oauth_state");

  return c.redirect("/dashboard");
});

export default app;
```

## Protected route

Read the session cookie and display user information. The AuthPI SDK can reconstitute an agent from stored tokens and will auto-refresh expired access tokens. With arctic, you fetch the userinfo endpoint manually.

**@authpi/idp:**
```typescript
// src/routes/dashboard.ts
import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import type { Bindings } from "../types";
import { createAuthClient } from "../auth";

const app = new Hono<{ Bindings: Bindings }>();

app.get("/dashboard", async (c) => {
  const sessionCookie = getCookie(c, "session");

  if (!sessionCookie) {
    return c.redirect("/auth/login");
  }

  const authClient = createAuthClient(c.env);
  const tokens = JSON.parse(sessionCookie);
  const agent = await authClient.createAgent(tokens);

  return c.html(`
    <html>
      <body>
        <h1>Dashboard</h1>
        <p>User ID: ${agent.id}</p>
        <p>Email: ${agent.email}</p>
        <a href="/auth/logout">Log out</a>
      </body>
    </html>
  `);
});

export default app;
```

**arctic:**
```typescript
// src/routes/dashboard.ts
import { Hono } from "hono";
import { getCookie } from "hono/cookie";
import type { Bindings } from "../types";
import { createOAuthClient } from "../auth";

const app = new Hono<{ Bindings: Bindings }>();

app.get("/dashboard", async (c) => {
  const sessionCookie = getCookie(c, "session");

  if (!sessionCookie) {
    return c.redirect("/auth/login");
  }

  const { issuerUrl } = createOAuthClient(c.env);
  const session = JSON.parse(sessionCookie);

  const response = await fetch(`${issuerUrl}/userinfo`, {
    headers: { Authorization: `Bearer ${session.accessToken}` },
  });

  if (!response.ok) {
    return c.redirect("/auth/login");
  }

  const user: Record<string, string> = await response.json();

  return c.html(`
    <html>
      <body>
        <h1>Dashboard</h1>
        <p>User ID: ${user.sub}</p>
        <p>Email: ${user.email}</p>
        <a href="/auth/logout">Log out</a>
      </body>
    </html>
  `);
});

export default app;
```

## Logout route

Revoke the refresh token, clear the session cookie, and redirect to AuthPI's logout endpoint so the IdP session is also terminated.

**@authpi/idp:**
```typescript
// src/routes/logout.ts
import { Hono } from "hono";
import { getCookie, deleteCookie } from "hono/cookie";
import type { Bindings } from "../types";
import { createAuthClient } from "../auth";

const app = new Hono<{ Bindings: Bindings }>();

app.get("/auth/logout", async (c) => {
  const sessionCookie = getCookie(c, "session");
  const authClient = createAuthClient(c.env);

  if (sessionCookie) {
    const tokens = JSON.parse(sessionCookie);

    if (tokens.refreshToken) {
      await authClient.revokeToken(tokens.refreshToken, "refresh_token");
    }
  }

  const logoutUrl = authClient.createLogoutUrl({
    postLogoutRedirectUri: c.env.BASE_URL,
  });

  deleteCookie(c, "session");

  return c.redirect(logoutUrl.toString());
});

export default app;
```

**arctic:**
```typescript
// src/routes/logout.ts
import { Hono } from "hono";
import { getCookie, deleteCookie } from "hono/cookie";
import type { Bindings } from "../types";
import { createOAuthClient } from "../auth";

const app = new Hono<{ Bindings: Bindings }>();

app.get("/auth/logout", async (c) => {
  const sessionCookie = getCookie(c, "session");
  const { client, issuerUrl } = createOAuthClient(c.env);

  if (sessionCookie) {
    const session = JSON.parse(sessionCookie);

    if (session.refreshToken) {
      await client.revokeToken(
        `${issuerUrl}/revoke`,
        session.refreshToken
      );
    }
  }

  const logoutUrl = new URL(`${issuerUrl}/logout`);
  logoutUrl.searchParams.set("post_logout_redirect_uri", c.env.BASE_URL);

  deleteCookie(c, "session");

  return c.redirect(logoutUrl.toString());
});

export default app;
```

## Next steps

You now have a working login/logout flow. From here you can:

- Add [Organizations](/docs/concepts/organizations) support for multi-tenant access control
- Subscribe to [Events](/docs/concepts/events) for audit logging
- Set up [Webhooks](/docs/guides/webhooks) for real-time notifications when users sign up, update their profile, or change authentication methods

The AuthPI SDK handles token refresh automatically via `createAgent()`. If you are using arctic, you will need to implement refresh logic manually by calling the `/token` endpoint with `grant_type=refresh_token` when the access token expires.