fres-sudo/hono-auth(opens in a new tab)
Language: TypeScript
Opinionated Hono, Drizzle, Zod Authentication System š
- stars
- 16
- watchers
- 1
- forks
- 1
- open issues
- 0
- Last updated
- 11/24/2025
Language: TypeScript
Opinionated Hono, Drizzle, Zod Authentication System š
In this article I'm will try my best to explain to you about the creation from the ground up of a complete authentication system for your next successful application.
I'm sorry for any grammatical errors, this is my firs article and I'm not mother tongue.
Here it is a quick break down of what functionalities your app will have at the end of the article:
Those are the main technologies and libraries that you will need to spin up this simple application:
tsrynge an easy library for Dependence Injection in Typescript,Notice that you can basically use what technologies you prefer, those are just based on my personal preference, you don't have to stick with them, but also notice that if you choose to go with something else you will have to make some changes at your code.
The architecture is heavy inspired by this awesome project, I'll just mention more or less what the creator said in his introduction.
There are a few popular architectures for structuring backends. Technical, Onion, DDD, VSA, and the list goes on.
controllers - Responsible for routing requests
services - Responsible for handling business logic.
repositories - Responsible for retrieving and storing data.
infrastructure - Handles the implementation of external services or backend operations.
middleware - Middlware our request router is responsible for handling.
providers - Injectable services
dtos - Data Transfer Objects (DTOs) are used to define the shape of data that is passed.
Each file of the project is postfixed with its architectural type(e.g. iam.service.ts). This allows
us to easily reorganize the folder structure to suite a different architecture pattern if the domain becomes more complex.
For example, if you want to group folders by domain(DDD), you simply drag and drop all related files to that folder.
āāā events/
āāā events.controller.ts
āāā events.service.ts
āāā events.repository.ts
I am making this article to help people struggling with finding a resource that will help them to spin up a complete authentication system, often the guides and video tutorial online not includes all the functionalities that I will show you in this article.
The claim is to have a place to get a look when you feel lost while writing your authentication system.
This IS NOT the perfect authentication system, this is just mine authentication system.
Authentication is usually a non-trivial area, so use my implementation at your own risk.
This guide is for developers that already know the basics of Javascript or Typescript, you don't need to know any JS framework, if you already play with Express.js or something like that you will probably will understand more, but it is not required.
Of course basic understanding of how an HTTP server works and the interaction between client and server is mandatory, also what is a RESTful API and how it works.
In this article I will not go in the details of the architectural implementation of the system, or how to install the software, it will use bun as package manager, but you can use whatever you prefer, it will change quite nothing.
Also a basic understand of Docker is better to complete understand the course.
No worries, you will find all the code in this Github repository. If you will find this article useful I will highly appreciate a Github ā I still highly suggest you to follow
To get started right we first need to create a new project.
bun create hono@latest
This command will guide you through a wizard for the creation of your bun application, the process for other packer manager is more or the less the same.
The first thing you will get asked is to choose a target directory, this means that if you are already in your project directory you just need to type ., otherwise just type the name of the folder of the project, I will use hono-auth, you can choose your own.
After that you will prompted to choose from a template, this is the most important part, for this guide I will choose bun, this will create a basic src/index.ts file, that is the entry point of our new application. It will also generate other files, such as the configuration of Typescript in tsconfig.json and the most important, the package.json, in here we can see that bun create a simple script for run our app.
"scripts": { "dev": "bun run --hot src/index.ts" }
Notice that if you choose other template for other kind of project your output would be slightly different, choose what you need!
If he ask you to install the project dependencies just say yes, and for the package manger just choose what you prefer, as I said, I'll stick with bun.
So if we run our app with bun dev and we try to call the localhost:3000/ endpoint we should get some feedback.
I will use httppie that is just a CLI tool that can make you make request to endpoints, but you can use Postman or whatever you prefer.
so your output would be:
Hello Hono!or something like that.
Before even touch any code, let's add some packages to our project so we don't have to do it later on the fly.
Just type in the terminal:
bun add tsyringe drizzle-kit drizzle-orm drizzle-zod zod reflect-metadata typescript arctic postgres nodemailer hono-rate-limiter @hono/zod-validator @paralleldrive/cuid2
Those are the main dependencies for this project, you just add those and you are ready to go.
Before starting there will be a quite long session of setup before starting. I'll break down the major steps for you, be sure to follows every single step to not get lost.
Let's get started.
This step is not necessary but I personally highly reccomend to follow up and use docker, to avoid any problems related to your machine.
I will not explain the reason to choose docker, just do it, and you will not regret it! Aside from jokes, Docker will help us to ensure that we all got the same setup and you work on an isolated environment.
So first thing first you might need to install Docker (and Docker Compose) on you machine, you can find the installation here, if you are new to docker I will suggest you to download also the desktop version if is your first time with docker so you can visually see what is going on.
So the first thing we need in our isolated environment is obviously our app and our dependencies, so we can create an entry point for docker to download it and serve to us.
This will be accomplished by creating a Dockerfile in our root directory, so in my case it will be hono-auth/Dockerfile:
FROM oven/bun:1.0.35
WORKDIR /home/bun/app
COPY ./package.json .
RUN bun install
COPY . .
CMD [ "bun", "run", "dev" ]
So basically here we are downloading bun, setting up out work directory, copying the package.json with all the necessary information and then install the dependencies in the environment, after that we're also telling docker that it has to execute bun run dev.
Now that we have our entry- point, since we will have two different containers, running at the same time, we have to define a Docker Compose file, to handle the two container and the communication between the two.
For doing that we will need to create docker-compose.yaml file in out root directory.
services: app: container_name: hono-app build: dockerfile: Dockerfile depends_on: - postgres env_file: - .env ports: - 3000:3000 restart: always networks: - app-network postgres: container_name: db image: postgres:latest environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: postgres ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data networks: - app-network networks: app-network: driver: bridge volumes: postgres_data:
So as you can see we are defining 2 differnet services:
app : this will be the hono app, and it has a docker file, that is the file that we created earlier, it obviously depends on the db, since we need first the db to make the app starts, we define the .env file with all our secrets, the ports that we are using in the project and the port we want to expose on docker, and of course the network. The network is just a bridge that ensure the communication between 2 or more services (or containers).
db : this is the postgres instance that we will use as databse in our app, the paramteres are quite the same and self explanatory.
Once we setup everything we can now define some scripts to effectively runs these 2 containers.
so in the package.josn we can define:
"scripts": { "dev": "bun run --hot src/index.ts", "docker:up": "docker-compose up -d", "docker:build": "docker-compose up -d --build", }
Even if Drizzle is a fast ORM, it will require a small setup, nothing too crazy, don't worry. For more detailed introduction to setup Drizzle ORM I higly suggest you to check out their official documentation that is very great.
If you are using external database, like Neon, Supabase, ecc... Feel free to put your url as env variable.
To connect to the PostgreSQL database, you need to provide the database URL. The URL format is:
postgres://<user>:<password>@<host>:<port>/<database>
So create a .env file at root of the application and fill with the values you choose in the docker compose, in our case is:
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres
For the next setup we also need to define some structure of out environment variables, so we can do that as follows.
Since I'm using bun, the engine gives to me some useful API to retrive environment variables, without the need of external packages.
Please notice that if you are using other engine, we will need some adjustment and most likely also using a 3rd part package to interact with env variables.
In bun we can retrive an environment variable in some way, for personal preference I will use:
process.env.[ENV_VARIABLE_NAME]
For a better structured setup, create a config.type.ts under src/types/.
// src/types/config.type.ts export const config: Config = { isProduction: process.env.NODE_ENV === "production", api: { origin: process.env.ORIGIN ?? "", }, postgres: { url: process.env.DATABASE_URL ?? "", }, }; interface Config { isProduction: boolean; api: ApiConfig; postgres: PostgresConfig; } interface ApiConfig { origin: string; } interface PostgresConfig { url: string; }
As you can see I also add a quite useful environment variable that is:
ORIGIN=http://localhost:3000 //3000 is the port we define in the docker-compose.yaml file
Since now the project is local, we just use localhost but when you will deploy it you will put here your correct url.
For correctly setup drizzle we need to define a configuration file, that will be placed in the root of our application, so in this case it will be hono-auth/drizzle.config.ts, this file will contains all the necessary information for drizzle to spin up.
// drizzle.config.ts import type { Config } from "drizzle-kit"; import { config } from "./src/types/config.type"; export default { out: "./src/infrastructure/database/migrations", schema: "./src/infrastructure/database/tables/index.ts", breakpoints: false, strict: true, dialect: "postgresql", dbCredentials: { url: config.postgres.url, }, migrations: { table: "migrations", schema: "public", }, verbose: true, } satisfies Config;
As you can see a lot of stuff are going on here, but let's break down the important parts real quick.
The Drizzle entry point is basically where your app tells drizzle to connect with databse, in our case it will be located under.
In our case I prefer to locate it under src/infrastructure/database/index.ts, the file contente of index.ts will be:
// src/infrastructure/database/index.ts import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./tables"; import { config } from "../../types/config.type"; export const client = postgres(config.postgres.url, { max: 10 }); export const db = drizzle(client, { schema });
So far our infrastructure structure looks like this:
āāā infrastructure/
āāā database/
āāā utils.ts
āāā index.ts
āāā migrations/
As you can see there are also some files that we didn't meet so far. Let's break it down:
//utils.ts import { HTTPException } from "hono/http-exception"; export const takeFirst = <T>(values: T[]): T | null => { if (values.length === 0) return null; return values[0]!; }; export const takeFirstOrThrow = <T>(values: T[]): T => { if (values.length === 0) throw new HTTPException(404, { message: "Resource not found", }); return values[0]!; };
This file contains two crucial function that we will be using a lot in our queries.
The migration is the directory that we pointed in the index.ts file, and it will contains all our migration .sql file for out database.
As I said before for dependency injection, I'm going to use tsyringe but you can actually use whatever you want.
If you will use both bun and tsyringe you just need a small fix to make everything works, just follows these steps:
Create reflect-metadata-import.ts under src/ folder,
// src/reflect-metadata-import.ts import "reflect-metadata";
Create bunfig.toml file under root folder
// /bunfig.toml preload = ["./src/reflect-metadata-import.ts"]
Once you are done with the initial setup, we can now pass to implements the first step, that will be the creation of a basic email and password authentication.
Let's start by creating the controller, as I said in the Architecture Overview the controllers are responsible for routing requests.
Before define the actual controller let's explicit define how a controller is shaped.
Create a interfaces/ folder under src, and add the controller.interface.ts file.
This file as expected will contains the shape of all out Controller classes.
// src/interfaces/controller.interface.ts import { Hono } from "hono"; import type { HonoTypes } from "./../types/hono.type"; import type { BlankSchema } from "hono/types"; export interface Controller { controller: Hono<HonoTypes, BlankSchema, "/">; routes(): any; }
The HonoTypes is an handy type used to define some global type, let's define it in the src/types/hono.type.ts file.
//src/types/hono.type.ts import type { Promisify, RateLimitInfo } from "hono-rate-limiter"; export type HonoTypes = { Variables: { userId: string; rateLimit: RateLimitInfo; rateLimitStore: { getKey?: (key: string) => Promisify<RateLimitInfo | undefined>; resetKey: (key: string) => Promisify<void>; }; }; };
Now let's define the Authentication Controller, create a controllers/ folder under src/ and add auth.controller.ts file.
Here we have to define a controller class that implements the Controller interface. Let's do it.
// src/controllers/auth.controller.ts import { Hono } from "hono"; import type { HonoTypes } from "./../types/hono.type"; import { injectable } from "tsyringe"; import { Controller } from "../interfaces/controller.interface"; @injectable() export class AuthController implements Controller { controller = new Hono<HonoTypes>(); constructor() {} routes() { return this.controller; } }
This is the shape of our AuthController and it will be the shape of all the other controller that you will implements in your application.
The @injectable keywords is a decorator that tells us that this class is a dependency of the project and we can retrive its instance everywhere in the application, because it will be injected at runtime, I link here the official documentation.
Then we will se that we can retrive it by doing:
const instance = container.resolve(AuthController);
But the question is: where do I have to retrieve it?
Easy, in the index.ts . This file is very important, it is the entry point of your application, every thing starts from there.
Most likely, by running
bun create hono@latest
You will probably get a index.ts like this:
// src/index.ts import { Hono } from "hono"; const app = new Hono(); app.get("/", (c) => { return c.text("Hello Hono!"); }); export default app;
We will now edit a bit to reflect our needs.
// src/index.ts import "reflect-metadata"; import { Hono } from "hono"; import { container } from "tsyringe"; import { AuthController } from "./controllers/auth.controller"; /* ----------------------------------- Api ---------------------------------- */ const app = new Hono().basePath("/api"); /* --------------------------------- Routes --------------------------------- */ const authRoutes = container.resolve(AuthController).routes(); app.route("/auth", authRoutes); app.get("/", (c) => { return c.text("Hello Hono!"); }); /* -----------------------------------Exports----------------------------------*/ export default app;
Seems all clear:
/api route.AuthController./auth route, for handling our authentication routes.import "reflect-metadata"; for the tsyringe lib on bun.GET endpoint on / for now, just for testing purpose, but it is up to you.Now that we have our controller we can just define routes inside of it.
As you can imagine for now we need just login and signup, let's see how can we do it in Hono with our setup.
// src/controllers/auth.controller.ts import { Hono } from "hono"; import type { HonoTypes } from "./../types/hono.type"; import { inject, injectable } from "tsyringe"; import { Controller } from "../interfaces/controller.interface"; import { zValidator } from "@hono/zod-validator"; import { loginDTO } from "../dtos/login.dto"; import { signUpDTO } from "../dtos/signup.dto"; import { AuthService } from "../services/auth.service"; import { limiter } from "../middlewares/limiter.middleware"; @injectable() export class AuthController implements Controller { controller = new Hono<HonoTypes>(); constructor(@inject(AuthService) private readonly authService: AuthService) {} routes() { return this.controller .post( "/login", zValidator("json", loginDTO), limiter({ limit: 10, minutes: 60 }), async (context) => { const body = context.req.valid("json"); const user = await this.authService.login(body); return context.json(user); } ) .post( "/signup", zValidator("json", signUpDTO), limiter({ limit: 10, minutes: 60 }), async (context) => { const data = context.req.valid("json"); const newUser = await this.authService.signup(data); return context.json(newUser); } ); } }
Of course we need 2 POST endpoint, and in Hono, you can pass few things as parameter in a route, I will not go in detail of everything since we will see what we need on the fly.
For now you need to know that:
As you can see I keep the response JSON as clean as possibile, to make it easier to deserialize in the frontend, but it is just my preference, you can pick wich standard you most like, in this case I will highly suggest you to make a custom response object, to be as consistent as possible.
There are a lot of stuff beign added in this snippet, so let's try to make some order.
The zValidator() function comes from the zod-validator hono library, as you can imagine, this function actually takes 2 parameters, a string, that will be used to validate it, and a zod object.
I prefer to keep all the DTOs (Data Transfer Object) in a separate folder. You can create the folder /dtos under /src and create the file login.dto.ts and user.dto.ts.
Data Transfer Objects (DTOs) are used to define the shape of data that is passed. They are used to validate data and ensure that the correct data is being passedto the correct methods.
For the Login DTO is all simple, just email and password, so:
// src/dtos/login.dto.ts import { z } from "zod"; export const loginDTO = z.object({ email: z .string({ required_error: "email-required", }) .email(), password: z .string({ required_error: "password-required", }) .min(8, "password-too-short") .max(32, "password-too-long"), }); export type LoginDTO = z.infer<typeof loginDTO>;
As you can see in Zod you can also include custom error messages, and custom type of validation, I also infer the type from the zod object, so we can use it also in the service, as object that is passed from the controller, it will be much cleaner in a minute.
Of course in the body of the function I actually validate this object that I recive as a request with:
const data = context.req.valid("json");
We use "json" as a key, and is the same key we put in the zValidator(). This process ensure us that we will have full type safety across the endpoint, and we do not get some data that we don't actually need.
The next step is to do the same for Sign Up DTO,
// src/dtos/signup.dto.ts import { z } from "zod"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { usersTable } from "./../tables"; export const signUpDTO = createInsertSchema(usersTable) .extend({ passwordConfirmation: z.string({ required_error: "password-confirmation-required", }), email: z.string().email(), }) .omit({ id: true, createdAt: true, updatedAt: true, }) .refine((data) => data.password === data.passwordConfirmation, { message: "passwords-donot-match", path: ["passwordConfirmation"], }); export type SignUpDTO = z.infer<typeof signUpDTO>;
So now the "music" is a bit different than before, as you can see we introduce two new things, createInsertSchema and usersTable.
The createInsertSchema() is a function that comes from drizzle-zod a very convenient package to use in combo with Drizzle. It provides some time-saving API that will "translate" your Drizzle Schema into zod object, both for insert and for the selection.
So the createInsertSchema() will take a Zod Schema as an argument and will return a Zod Object.
For the usersTable we will get there in a minute.
In this case we do also need some other adjustment to the created Zod Object, since we have to extend it with the password confirmation and we of course do not need to insert the id, createdAt and updatedAt when we create a new user from the signup form.
We finally refine the Zod Object by ensuring that the password and passwordConfirmation are the same.
I talk about zod validation and I mention the usersTable but I actually never declare it, let's do it right now.
As I said early in the article we will store all the Drizzle Schema of the database under the src/infrastructure/database/tables/ folder, so create the users.table.ts file under this folder.
// src/infrastructure/database/tables/users.table.ts import { citext, timestamps } from "./utils"; import { boolean, pgTable, text } from "drizzle-orm/pg-core"; import { createId } from "@paralleldrive/cuid2"; export const usersTable = pgTable("users", { id: text("id") .primaryKey() .$defaultFn(() => createId()), email: citext("email").notNull().unique(), password: text("password").notNull(), ...timestamps, });
This is the structure of the usersTable so far, I will not go in detail of the Drizzle implementation, you can find a good guide on their documentation.
Just a quick reminder to create a src/infrastructure/database/tables/index.ts file:
// src/infrastructure/database/tables/index.ts export * from "./users.table";
In that way we can reference all the schemas in one in the drizzle.config.ts, we just mention last chapter.
// src/infrastructure/database/index.ts import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./tables"; // Add the import here export const client = postgres(Bun.env.DATABASE_URL ?? "", { max: 10 }); export const db = drizzle(client, { schema });
There are somethings that we actually did not encounter so far, the createId() function, that is just a function to create reliable id from the paralleldrive library, but also the citext and timestamps.
The citext represent the case insensitive text and it is just a customType for Drizzle, and the timestamps as you can imagine are just the createdAt and updatedAt fields "merged together".
You can place both under the src/infrastructure/database/tables/utils.ts file, that you have to create.
// src/infrastructure/database/tables/utils.ts import { timestamp } from "drizzle-orm/pg-core"; import { customType } from "drizzle-orm/pg-core"; export const citext = customType<{ data: string }>({ dataType() { return "citext"; }, }); export const timestamps = { createdAt: timestamp("created_at", { mode: "date", withTimezone: true, }) .notNull() .defaultNow(), updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true, }) .notNull() .defaultNow(), };
A very important step is to create
The limiter middleware comes in handy when you have to ensure that users not spam your endpoints, so you can create a src/middleware/limiter.middleware.ts file.
// src/middleware/limiter.middleware.ts import { rateLimiter } from "hono-rate-limiter"; import type { HonoTypes } from "./../types/hono.type"; export function limiter({ limit, minutes, key = "", }: { limit: number; minutes: number; key?: string; }) { return rateLimiter({ windowMs: minutes * 60 * 1000, // every x minutes limit, // Limit each IP to 100 requests per `window` (here, per 15 minutes). standardHeaders: "draft-6", // draft-6: `RateLimit-*` headers; draft-7: combined `RateLimit` header keyGenerator: (c) => { const vars = c.var as HonoTypes["Variables"]; const clientKey = vars.userId || c.req.header("x-forwarded-for"); const pathKey = key || c.req.routePath; return `${clientKey}_${pathKey}`; }, // Method to generate custom identifiers for clients. }); }
As you can see I almost cover everything I show you in the AuthController but I didn't mention the most important part, the AuthService.
As I mention before services in general are responsible for handling business logic.
Obviously we will use Dependency Injection also for the Services, the base of the AuthService and in general of the other services too, will be a basic @inje