How to use Own Auth with Hono
Run Own Auth behind Hono routes and store its opaque session token in a Secure HttpOnly cookie. Hono middleware verifies the session and exposes safe user data through typed context variables.
The implementation covers email and password sign-in, protected routes, magic links, and immediate session revocation across supported Hono runtimes.
What you will build
- A server-only Own Auth instance
- Sign-up and sign-in routes
- An opaque session token stored in an HttpOnly cookie
- Middleware that validates the session and sets typed context variables
- Protected routes that require a valid session
- Magic-link request and verification routes
- Sign-out with immediate session revocation
How Own Auth fits into Hono
Browser request
-> Hono middleware (validate session cookie)
-> Hono route handler
-> own-auth
-> Postgres
The browser stores one opaque HttpOnly session token.
Middleware validates it and sets c.set('currentAuth', result).
Protected routes read c.get('currentAuth') and enforce authorization.Install and migrate
Install Own Auth and a schema validator, then apply the Own Auth migrations to your Postgres database.
npm install own-auth hono zod
npx own-auth migrate
npx own-auth statusOwn Auth manages only its own auth tables. If the application also uses Prisma, Drizzle, or another migration tool, keep each tool responsible for its own tables.
Configure environment values
DATABASE_URL=postgres://user:password@localhost:5432/myapp
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
APP_URL=http://localhost:3000Generate the token pepper once, store it with production secrets, and keep it stable. Changing it invalidates existing sessions, authentication links, SMS codes, and API keys.
Create the Own Auth instance
Keep the Own Auth instance in its own module so every route file imports the same object. Own Auth reads DATABASE_URL from the environment automatically.
import { createOwnAuth } from 'own-auth';
const tokenPepper = process.env.OWN_AUTH_TOKEN_PEPPER;
const baseUrl = process.env.APP_URL;
if (!tokenPepper) throw new Error('OWN_AUTH_TOKEN_PEPPER is required');
if (!baseUrl) throw new Error('APP_URL is required');
export const auth = createOwnAuth({ tokenPepper, baseUrl });Define session cookie helpers
Hono provides getCookie, setCookie, and deleteCookie from hono/cookie. Wrap them to keep the cookie name and options consistent across routes.
import type { Context } from 'hono';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
export const SESSION_COOKIE = 'own_auth_session';
export function getSessionToken(c: Context) {
return getCookie(c, SESSION_COOKIE);
}
export function setSessionCookie(
c: Context,
token: string,
expires: Date,
) {
setCookie(c, SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'Lax',
secure: process.env.NODE_ENV === 'production',
expires,
});
}
export function deleteSessionCookie(c: Context) {
deleteCookie(c, SESSION_COOKIE, { path: '/' });
}Use Secure cookies in production and keep the path consistent when setting and deleting them. SameSite=Lax is a practical default for a first-party application while still allowing top-level navigation from an authentication email.
Add session middleware
Hono middleware reads the cookie, validates the database-backed session through Own Auth, and stores the result in the request context with c.set. Delete an expired or revoked cookie so the browser stops sending it.
import { createMiddleware } from 'hono/factory';
import { auth } from './auth';
import { getSessionToken, deleteSessionCookie } from './session-cookie';
type AuthEnv = {
Variables: {
currentAuth: Awaited<ReturnType<typeof auth.getCurrentSession>>;
};
};
export const sessionMiddleware = createMiddleware<AuthEnv>(
async (c, next) => {
const token = getSessionToken(c);
const currentAuth = token
? await auth.getCurrentSession(token)
: null;
if (token && !currentAuth) {
deleteSessionCookie(c);
}
c.set('currentAuth', currentAuth);
await next();
},
);The AuthEnv type keeps c.get('currentAuth') typed throughout downstream handlers. Apply this middleware to the top-level app so every route receives the session result.
Create the sign-in route
Accept a JSON body with email and password, validate the input, call Own Auth, and set the session cookie. Return a JSON response so a frontend client can handle the result.
import { Hono } from 'hono';
import { AuthError } from 'own-auth';
import { z } from 'zod';
import { auth } from './auth';
import { setSessionCookie } from './session-cookie';
import { sessionMiddleware } from './middleware';
const app = new Hono();
app.use('*', sessionMiddleware);
const signInSchema = z.object({
email: z.string().trim().email(),
password: z.string().min(1),
});
app.post('/auth/signin', async (c) => {
const body = await c.req.json();
const parsed = signInSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'Enter a valid email and password.' }, 400);
}
try {
const result = await auth.signInEmailPassword(parsed.data);
setSessionCookie(c, result.sessionToken, result.session.expiresAt);
return c.json({ ok: true });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: error.safeMessage }, error.statusCode);
}
throw error;
}
});Wrong emails and wrong passwords both produce Own Auth's generic invalid-credentials error. Preserve that ambiguity so the response does not reveal which accounts exist.
Add sign-up
The sign-up route follows the same pattern. Validate the name, email, and password, call signUpEmailPassword, set the returned session cookie, and return a success response.
const signUpSchema = z.object({
name: z.string().trim().min(1),
email: z.string().trim().email(),
password: z.string().min(1),
});
app.post('/auth/signup', async (c) => {
const body = await c.req.json();
const parsed = signUpSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'Name, email, and password are required.' }, 400);
}
try {
const result = await auth.signUpEmailPassword(parsed.data);
setSessionCookie(c, result.sessionToken, result.session.expiresAt);
return c.json({ ok: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: error.safeMessage }, error.statusCode);
}
throw error;
}
});Protect routes with middleware
Create a second middleware that requires a valid session. Apply it to route groups that need authentication. Return only the user fields the client needs. The session token stays in the HttpOnly cookie and must not appear in response bodies.
import { createMiddleware } from 'hono/factory';
type AuthEnv = {
Variables: {
currentAuth: NonNullable<
Awaited<ReturnType<typeof auth.getCurrentSession>>
>;
};
};
export const requireAuth = createMiddleware<AuthEnv>(
async (c, next) => {
const currentAuth = c.get('currentAuth');
if (!currentAuth) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
},
);app.get('/api/me', requireAuth, (c) => {
const { user } = c.get('currentAuth');
return c.json({ user });
});
app.get('/api/dashboard', requireAuth, (c) => {
const { user } = c.get('currentAuth');
return c.json({ message: `Welcome, ${user.name}` });
});Sign out and revoke the session
Revoke the database session before deleting the browser cookie. Use a POST route because sign-out changes server state.
app.post('/auth/signout', async (c) => {
const token = getSessionToken(c);
try {
if (token) await auth.signOut(token);
} finally {
deleteSessionCookie(c);
}
return c.json({ ok: true });
});Add magic-link sign-in
Configure an Own Auth email provider, then add one route to request the email and one to consume the one-time token. Always return the same confirmation message regardless of whether an account exists.
app.post('/auth/magic-link', async (c) => {
const { email } = await c.req.json();
if (!email || typeof email !== 'string') {
return c.json({ error: 'Email is required.' }, 400);
}
await auth.requestMagicLink({ email: email.trim() });
return c.json({
message: 'If that address can sign in, a link is on its way.',
});
});app.get('/auth/magic-link/verify', async (c) => {
const token = c.req.query('token');
if (!token) {
return c.redirect('/signin?error=invalid_link');
}
try {
const result = await auth.verifyMagicLink({ token });
setSessionCookie(c, result.sessionToken, result.session.expiresAt);
return c.redirect('/dashboard');
} catch (error) {
if (error instanceof AuthError) {
return c.redirect('/signin?error=invalid_link');
}
throw error;
}
});Full application entry point
The assembled entry point applies session middleware globally and registers the auth routes together.
import { Hono } from 'hono';
import { AuthError } from 'own-auth';
import { z } from 'zod';
import { auth } from './auth';
import { sessionMiddleware, requireAuth } from './middleware';
import {
getSessionToken,
setSessionCookie,
deleteSessionCookie,
} from './session-cookie';
const app = new Hono();
app.use('*', sessionMiddleware);
// --- Sign-in ---
const signInSchema = z.object({
email: z.string().trim().email(),
password: z.string().min(1),
});
app.post('/auth/signin', async (c) => {
const body = await c.req.json();
const parsed = signInSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'Enter a valid email and password.' }, 400);
}
try {
const result = await auth.signInEmailPassword(parsed.data);
setSessionCookie(c, result.sessionToken, result.session.expiresAt);
return c.json({ ok: true });
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: error.safeMessage }, error.statusCode);
}
throw error;
}
});
// --- Sign-up ---
const signUpSchema = z.object({
name: z.string().trim().min(1),
email: z.string().trim().email(),
password: z.string().min(1),
});
app.post('/auth/signup', async (c) => {
const body = await c.req.json();
const parsed = signUpSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'Name, email, and password are required.' }, 400);
}
try {
const result = await auth.signUpEmailPassword(parsed.data);
setSessionCookie(c, result.sessionToken, result.session.expiresAt);
return c.json({ ok: true }, 201);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: error.safeMessage }, error.statusCode);
}
throw error;
}
});
// --- Sign-out ---
app.post('/auth/signout', async (c) => {
const token = getSessionToken(c);
try {
if (token) await auth.signOut(token);
} finally {
deleteSessionCookie(c);
}
return c.json({ ok: true });
});
// --- Magic link ---
app.post('/auth/magic-link', async (c) => {
const { email } = await c.req.json();
if (!email || typeof email !== 'string') {
return c.json({ error: 'Email is required.' }, 400);
}
await auth.requestMagicLink({ email: email.trim() });
return c.json({
message: 'If that address can sign in, a link is on its way.',
});
});
app.get('/auth/magic-link/verify', async (c) => {
const token = c.req.query('token');
if (!token) {
return c.redirect('/signin?error=invalid_link');
}
try {
const result = await auth.verifyMagicLink({ token });
setSessionCookie(c, result.sessionToken, result.session.expiresAt);
return c.redirect('/dashboard');
} catch (error) {
if (error instanceof AuthError) {
return c.redirect('/signin?error=invalid_link');
}
throw error;
}
});
// --- Protected routes ---
app.get('/api/me', requireAuth, (c) => {
const { user } = c.get('currentAuth');
return c.json({ user });
});
export default app;Production checklist
- Serve the Hono application over HTTPS in production
- Keep the Own Auth instance, DATABASE_URL, and OWN_AUTH_TOKEN_PEPPER on the server
- Store the opaque session token only in a Secure HttpOnly cookie
- Validate the database session in middleware and re-check in each protected handler
- Use the verified Own Auth user ID for ownership and authorization checks
- Use POST routes for sign-in, sign-up, and sign-out
- Return generic errors for invalid credentials and magic-link requests
- Never serialize or log passwords, session tokens, provider credentials, or magic-link URLs
FAQ
Does Own Auth work on all Hono runtimes?
Own Auth needs a Postgres connection, which is available on Node, Bun, Deno, and Cloudflare Workers (via connection strings or Hyperdrive). The code in this guide works on all four. On Cloudflare Workers, pass the database connection string from c.env instead of process.env.
Should I store the session in a Hono variable instead of a cookie?
c.set and c.get hold request-scoped data that lives only for the current request. The session token must persist across requests, so it belongs in an HttpOnly cookie. The middleware reads the cookie, validates it, and places the result in c.set for downstream handlers.
Is checking the session in middleware enough?
Middleware authenticates the request and makes the result available through c.get('currentAuth'). Each protected handler must still require that result and enforce the user's authorization for the requested resource.
Conclusion
The integration keeps responsibilities explicit. Own Auth owns authentication records and revocable database sessions. Hono owns routes, middleware, cookies, context variables, and authorization around application data. That separation keeps secrets off the client and avoids duplicating auth logic in the framework layer, and it works the same way regardless of which runtime hosts the Hono application.