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:3000Configure 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.