Quickstarts

Add authentication to a Hono app

Add authentication to your Hono app with AuthPI — using the AuthPI SDK or any OIDC client library.

Last updated 2026-06-11

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

npm install hono @authpi/idp
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.

// src/types.ts
export type Bindings = {
  AUTHPI_ISSUER_URL: string;
  AUTHPI_CLIENT_ID: string;
  AUTHPI_CLIENT_SECRET: string;
  BASE_URL: string;
};
# 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.

// 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`,
  });
}
// 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.

// 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;
// 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.

// 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;
// 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.

// 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;
// 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.

// 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;
// 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 support for multi-tenant access control
  • Subscribe to Events for audit logging
  • Set up 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.