Hono, auth with jwt
A step-by-step guide to setting up a fast, modern API using Hono on Node.js.
October 6, 2025
You can see the full source code on GitHub.
For context, this tutorial extends the previous article Hono, Swagger, Pino.
Auth flow
The diagram below illustrates the core authentication endpoints of the API.
POST /auth/loginauthenticates a user with email and password, returning two HTTP-only cookies:authTokenandrefreshToken.GET /profile/meis a protected route that returns the authenticated user’s id, email, and role.POST /auth/logoutclears both cookies, ending the session.

In this article we focus only on the backend implementation. We use apps/backend as default path
Create services to handle auth
pnpm install argon2 jsonwebtoken
pnpm install -D @types/jsonwebtoken
let's use argon 2 to hash our password.
src/utils/jsonwebtoken.util.ts
// jwt.constant.js
// export const JWT_AUTH_SECRET = process.env.JWT_AUTH_SECRET;
// export const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
import jwt from 'jsonwebtoken';
import { JWT_AUTH_SECRET, JWT_REFRESH_SECRET } from '../config/jwt.constant.js';
declare module 'jsonwebtoken' {
export interface IDJwtPayload extends jwt.JwtPayload {
userId: string;
}
}
type Verify = [null, jwt.IDJwtPayload] | [jwt.TokenExpiredError | jwt.JsonWebTokenError | jwt.NotBeforeError, null];
export const verifyJwt = (token: string, secret: string): Verify => {
try {
return [null, <jwt.IDJwtPayload>jwt.verify(token, secret)];
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return [error, null];
}
if (error instanceof jwt.JsonWebTokenError) {
return [error, null];
}
if (error instanceof jwt.NotBeforeError) {
return [error, null];
}
throw error;
}
};
export const signAuthCookie = (userId: string, ttlSec: number) =>
jwt.sign(
{
userId,
},
JWT_AUTH_SECRET,
{
expiresIn: ttlSec,
},
);
export const signRefreshCookie = (userId: string, ttlSec: number) =>
jwt.sign(
{
userId,
},
JWT_REFRESH_SECRET,
{
expiresIn: ttlSec,
},
);
export const secondsUntil = (date: Date) => Math.max(0, Math.ceil((date.getTime() - Date.now()) / 1000));
export const getJwtExpirationDate = (timestamp: string) => new Date(Date.now() + Number.parseInt(timestamp, 10) * 1000);
src/utils/crypt.util.ts
import * as argon2 from "argon2";
export const hashPassword = async (password: string) =>
await argon2.hash(password);
export const compareHash = async (password: string, hash: string) =>
await argon2.verify(hash, password);
Error handler
Because the backend is responsible for clearing authentication cookies on the client, we need to return a custom response that explicitly deletes those cookies.
Simply throwing an HTTPException in Hono isn’t enough, since it doesn’t automatically modify or remove cookies.
src/utils/apiError.util.ts
import { HTTPException } from 'hono/http-exception';
type Cause = Record<string, unknown>;
type ErrorOptions = {
cause?: Cause;
res?: Response;
};
const MESSAGES = {
BAD_REQUEST: 'Bad request',
UNAUTHORIZED: 'Unauthorized',
FORBIDDEN: 'Forbidden',
NOT_FOUND: 'Not found',
CONFLICT: 'Conflict',
ZOD_ERROR: 'Zod error',
SERVICE_NOT_AVAILABLE: 'Service not available',
};
const getParamsOptions = (status: number, message: string, options?: ErrorOptions) => {
const params: { message: string; cause?: unknown } = { message };
if (options?.cause) {
params.cause = options.cause;
}
let res: Response;
if (options?.res) {
res = new Response(JSON.stringify({ ...params }), {
status,
headers: {
...Object.fromEntries(options.res.headers),
'Content-Type': 'application/json',
},
});
} else {
res = new Response(JSON.stringify({ ...params }), {
status: status,
headers: {
'Content-Type': 'application/json',
},
});
}
return { ...params, res };
};
export const HTTPException400BadRequest = (msg = MESSAGES.BAD_REQUEST, options?: ErrorOptions) => {
const status = 400;
const params = getParamsOptions(status, msg, options);
return new HTTPException(status, params);
};
export const HTTPException401Unauthorized = (msg = MESSAGES.UNAUTHORIZED, options?: ErrorOptions) => {
const status = 401;
const params = getParamsOptions(status, msg, options);
return new HTTPException(status, params);
};
export const HTTPException403Forbidden = (msg = MESSAGES.FORBIDDEN, options?: ErrorOptions) => {
const status = 403;
const params = getParamsOptions(status, msg, options);
return new HTTPException(status, params);
};
export const HTTPException404NotFound = (msg = MESSAGES.NOT_FOUND, options?: ErrorOptions) => {
const status = 404;
const params = getParamsOptions(status, msg, options);
return new HTTPException(status, params);
};
export const HTTPException500InternalServerError = (msg = MESSAGES.SERVICE_NOT_AVAILABLE, options?: ErrorOptions) => {
const status = 500;
const params = getParamsOptions(status, msg, options);
return new HTTPException(status, params);
};
We can now define a global error handler to catch exceptions and send consistent JSON responses for all API requests.
src/util/error.handler.ts
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { LoggerBindings } from '../factories/logger.factories.js';
export const errorHandler: ErrorHandler<LoggerBindings> = (err, c) => {
if (err instanceof HTTPException) {
return err.getResponse();
}
const logger = c.get('logger');
logger.error({ err }, 'Internal server error');
return c.json({ message: 'Internal server error' }, 500);
};
Update user
let's update feature/users to use a password and an email
src/features/users.schema.ts
import { ROLE } from "common";
import { z } from "zod";
export const UserSchema = z.object({
id: z.string(),
email: z.email(),
role: z.enum(Object.values(ROLE)),
password: z.string(),
});
export const UserWithoutPasswordSchema = UserSchema.omit({
password: true,
});
export const UsersWithoutPasswordSchema = z.array(UserWithoutPasswordSchema);
export const UserCreationSchema = z.object({
email: z.email().min(1, { message: "Email is required" }),
password: z
.string()
.min(6, { message: "Password must be at least 6 characters long" }),
});
Update the routes declarations
src/features/users.route.ts
import { describeRoute, resolver } from "hono-openapi";
import {
ErrorSchema,
ZodSafeParseErrorSchema,
} from "../../utils/error.schema.js";
import {
UserWithoutPasswordSchema,
UsersWithoutPasswordSchema,
} from "./users.schema.js";
export const getUserRoute = describeRoute({
tags: ["Users"],
responses: {
200: {
description: "Retrieve user by ID",
content: {
"application/json": {
schema: resolver(UserWithoutPasswordSchema),
},
},
},
400: {
description: "Error",
content: {
"application/json": {
schema: resolver(ErrorSchema),
},
},
},
},
});
export const getUsersRoute = describeRoute({
tags: ["Users"],
responses: {
200: {
description: "Retrieve users",
content: {
"application/json": {
schema: resolver(UsersWithoutPasswordSchema),
},
},
},
},
});
export const postUserRoute = describeRoute({
tags: ["Users"],
responses: {
201: {
description: "User created successfully",
content: {
"application/json": {
schema: resolver(UserWithoutPasswordSchema),
},
},
},
400: {
description: "Invalid input",
content: {
"application/json": {
schema: resolver(ZodSafeParseErrorSchema),
},
},
},
},
});
Update users service to prepare what we need
- Update
getUser,getUsers,createUserfor handle password and email - Add
getUserIfPasswordMatchto find a user at authentication
src/features/users.service.ts
import { ROLE } from 'common/constants';
import { getLoggerStore } from '../../utils/asyncLocalStorage.js';
import { compareHash, hashPassword } from '../../utils/crypt.util.js';
import type { User } from './user.type.js';
const users: User[] = [];
export const getUser = (id: string) => {
const user = users.find((user) => user.id === id);
if (!user) {
return null;
}
return { id: user.id, email: user.email, role: user.role };
};
export const getUserIfPasswordMatch = async (email: string, password: string) => {
const user = users.find((user) => user.email === email);
if (!user) {
return null;
}
const result = await compareHash(password, user.password);
if (!result) {
return null;
}
return { id: user.id, email: user.email, role: user.role };
};
export const getUsers = () => users.map((user) => ({ id: user.id, email: user.email, role: user.role }));
export const createUser = async (email: string, password: string) => {
const hash = await hashPassword(password);
const newUser = {
id: (users.length + 1).toString(),
email,
password: hash,
role: ROLE.USER,
};
const logger = getLoggerStore();
logger.info({ user: newUser }, 'Creating new user from service');
users.push(newUser);
return {
id: newUser.id,
email: newUser.email,
role: newUser.role,
};
};
Update the controller to handle the password
src/features/users/users.controller.ts
import { validator as zValidator } from 'hono-openapi';
import { createLoggerFactory } from '../../factories/logger.factories.js';
import { getUserRoute, getUsersRoute, postUserRoute } from './users.route.js';
import { UserCreationSchema } from './users.schema.js';
import { createUser, getUser, getUsers } from './users.service.js';
const app = createLoggerFactory
.createApp()
.get('/:id', getUserRoute, (c) => {
const id = c.req.param('id');
const user = getUser(id);
if (!user) {
return c.json({ message: 'User not found' }, 404);
}
return c.json(user);
})
.get('/', getUsersRoute, (c) => {
const users = getUsers();
return c.json(users);
})
.post('/', postUserRoute, zValidator('json', UserCreationSchema), async (c) => {
const { email, password } = await c.req.valid('json');
const newUser = await createUser(email, password);
const logger = c.get('logger');
logger.info({ user: newUser }, 'Creating new user from controller');
return c.json(newUser, 201);
});
export default app;
Auth feature
├── features/auth
│ ├── auth.controller.ts
│ ├── auth.route.ts
│ ├── auth.schema.ts
│ ├── auth.service.ts
│ ├── auth.type.ts
Schema for form validation.
features/auth/auth.schema.ts
import { z } from 'zod';
export const AuthSchema = z.object({
email: z.email(),
password: z.string().min(8).max(100),
});
export const LogoutSchema = z.object({
message: z.string(),
});
Session type we store
features/auth/auth.type.ts
export type Session = {
userId: string;
refreshToken: string;
createdAt: Date;
};
Routes for the swagger.
features/auth/auth.route.ts
import { describeRoute, resolver } from 'hono-openapi';
import { ZodSafeParseErrorSchema } from '../../utils/error.schema.js';
import { AuthSchema, LogoutSchema } from './auth.schema.js';
export const postLoginRoute = describeRoute({
summary: 'Login a user',
tags: ['Auth'],
responses: {
200: {
description: 'User logged in successfully',
content: {
'application/json': {
schema: resolver(AuthSchema),
},
},
},
400: {
description: 'Bad Request',
content: {
'application/json': {
schema: resolver(ZodSafeParseErrorSchema),
},
},
},
},
});
export const postLogoutRoute = describeRoute({
summary: 'Logout a user',
tags: ['Auth'],
responses: {
200: {
description: 'User logged out successfully',
content: {
'application/json': {
schema: resolver(LogoutSchema),
},
},
},
},
});
Simple session service.
features/auth/auth.service.ts
import type { Session } from './auth.type.js';
const sessions: Session[] = [];
export const createSession = (userId: string, refreshToken: string) => {
const session = {
userId,
refreshToken,
createdAt: new Date(),
};
sessions.push(session);
return session;
};
export const getSession = (refreshToken: string) => {
return sessions.find((session) => session.refreshToken === refreshToken);
};
export const deleteSession = (refreshToken: string) => {
const index = sessions.findIndex((session) => session.refreshToken === refreshToken);
if (index !== -1) {
sessions.splice(index, 1);
return true;
}
return false;
};
Our endpoints to handle login/logout logic.
features/auth/auth.controller.ts
// cookies.constant.js
// export const COOKIE_AUTH_EXPIRATION = process.env.COOKIE_AUTH_EXPIRATION || '900'; // 15 minutes
// export const COOKIE_REFRESH_EXPIRATION = process.env.COOKIE_REFRESH_EXPIRATION || '604800'; // 7 days
// export const COOKIE_AUTH_NAME = process.env.COOKIE_AUTH_NAME || 'auth_token';
// export const COOKIE_REFRESH_NAME = process.env.COOKIE_REFRESH_NAME || 'refresh_token';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { HTTPException } from 'hono/http-exception';
import { validator as zValidator } from 'hono-openapi';
import {
COOKIE_AUTH_EXPIRATION,
COOKIE_AUTH_NAME,
COOKIE_REFRESH_EXPIRATION,
COOKIE_REFRESH_NAME,
} from '../../config/cookies.constant.js';
import { createLoggerFactory } from '../../factories/logger.factories.js';
import { secondsUntil, signAuthCookie, signRefreshCookie } from '../../utils/jsonwebtoken.util.js';
import { getUserIfPasswordMatch } from '../users/users.service.js';
import { postLoginRoute, postLogoutRoute } from './auth.route.js';
import { AuthSchema } from './auth.schema.js';
import { createSession, deleteSession } from './auth.service.js';
const app = createLoggerFactory
.createApp()
.post('/login', postLoginRoute, zValidator('json', AuthSchema), async (c) => {
const { email, password } = await c.req.valid('json');
const logger = c.get('logger');
logger.info({ email }, 'Logging in user from controller');
const user = await getUserIfPasswordMatch(email, password);
if (!user) {
throw new HTTPException(401, { message: 'Invalid email or password' });
}
logger.info({ email }, 'User logged in successfully');
const authTokenDate = new Date(Date.now() + Number.parseInt(COOKIE_AUTH_EXPIRATION, 10) * 1000);
const refreshTokenDate = new Date(Date.now() + Number.parseInt(COOKIE_REFRESH_EXPIRATION, 10) * 1000);
const refreshToken = signRefreshCookie(user.id, secondsUntil(refreshTokenDate));
const authToken = signAuthCookie(user.id, secondsUntil(authTokenDate));
setCookie(c, COOKIE_AUTH_NAME, authToken, {
path: '/',
secure: true,
expires: authTokenDate,
sameSite: 'Strict',
});
setCookie(c, COOKIE_REFRESH_NAME, refreshToken, {
path: '/',
secure: true,
httpOnly: true,
expires: refreshTokenDate,
sameSite: 'Strict',
});
createSession(user.id, refreshToken);
return c.json({
id: user.id,
email: user.email,
role: user.role,
});
})
.post('/logout', postLogoutRoute, async (c) => {
const logger = c.get('logger');
logger.info('Logging out user from controller');
const refreshToken = getCookie(c, COOKIE_REFRESH_NAME);
if (refreshToken) {
deleteSession(refreshToken);
}
deleteCookie(c, COOKIE_AUTH_NAME);
deleteCookie(c, COOKIE_REFRESH_NAME);
return c.json({ message: 'Logged out' });
});
export default app;
Wrap everything now
features/auth/auth.controller.ts
Finally, register all routes and middlewares together in App.ts to initialize the complete application.
import { Hono } from "hono";
import { csrf } from "hono/csrf";
import authController from "./features/auth/auth.controller.js";
import profileController from "./features/profile/profile.controller.js";
import usersController from "./features/users/users.controller.js";
import { loggerMiddleware } from "./middlewares/pino.middleware.js";
import { errorHandler } from "./utils/error.handler.js";
const app = new Hono()
.use(csrf())
.use(loggerMiddleware)
.route("/auth", authController)
.route("/users", usersController)
.route("/profile", profileController)
.onError(errorHandler);
export default app;