Authentication System in Hono
Learn how to implement email/password authentication, JWT tokens, email verification, and password reset functionality from scratch.
December 27, 2024
Table of Contents
Introduction
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 first article and I'm not english mother tongue.
Functionalities
Here it is a quick break down of what functionalities your app will have at the end of the article:
- Email and password authentication
- JWT Access and Refresh Token System
- Email Verification
- Forgot password, OTP code verification, Reset password
Technologies
Those are the main technologies and libraries that you will need to spin up this simple application:
- Typescript, basically JS but a bit better,
- Hono, a ligthweigth Javascript framework to build fast web application,
- Drizzle ORM,
- Postgres SQL,
- Zod for DTOs,
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.
Architecture Overview
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.
Folder Structure
-
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.
File Naming
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
Why?
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.
Who?
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.
Where?
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
Getting Started
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.
Dependencies
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.
Setup
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.
- Docker Setup
- Drizzle Setup
- Configuration Setup
- Dipendence Injection Setup
Let's get started.
Docker Setup
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.
Dockerfile
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
.
Docker Compose
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",
}
Drizzle Setup
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.
Setup Env variables
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
Configuration File
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.
Drizzle Configuration
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.localUrl,
},
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.
- out is the output directory where the migrations will be localted, you can choose whatever you want;
- schema is the imporant file where drizzle will pull all the schemas of your database and will reflect your postgres instance accordingly
Drizzle Entry Point
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:
//src/infrastructure/database/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.
Dipendence Injection Setup
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
undersrc/
folder,// src/reflect-metadata-import.ts import "reflect-metadata";
-
Create
bunfig.toml
file under root folder// /bunfig.toml preload = ["./src/reflect-metadata-import.ts"]
Email and password authentication
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:
- We add a base path for the application, now all the routes that out app handle are under the
/api
route. - We "import" our instance of the
AuthController
. - We define the
/auth
route, for handling our authentication routes. - We add the
import "reflect-metadata";
for thetsyringe
lib to run onbun
. - We can keep the
GET
endpoint on/
for now, just for testing purpose, but it is up to you.
Defining routes
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,
@inject(RefreshTokenService)
private readonly refreshTokenService: RefreshTokenService,
@inject(EmailVerificationsService)
private readonly emailVerificationsService: EmailVerificationsService,
@inject(PasswordResetService)
private readonly passwordResetService: PasswordResetService
) {}
routes() {
return this.controller
.post(
"/login",
zValidator("json", loginDTO),
limiter({ limit: 10, minutes: 60 }),
async (context) => {
const body = context.req.valid("json");
const { user, accessToken, refreshToken } =
await this.authService.login(body);
return context.json({ user, accessToken, refreshToken });
}
)
.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:
- The first parameter is the path of your endpoint,
- You can eventually pass a validator and/or a limiter or whatever middleware you want,
- lastly you will have your function that will be exectued whenever the endpoint get triggered, usually it is asyncronous, for obvious reason.
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.
Zod DTOs Validation
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.
Users Table
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
Limiter Middleware
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.
});
}
Login and Signup Service
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 @injectable()
class with a bunch of methods:
@injectable()
export class AuthService {
constructor() {}
}
Inside of the service a right as we saw before, we'll need only two methods for now, login
and signup
.
// src/services/auth.service.ts
import { inject, injectable } from "tsyringe";
import { BadRequest, InternalError } from "../common/error";
import { HTTPException } from "hono/http-exception";
import { HashingService } from "./hashing.service";
import { LoginDTO } from "../dtos/login.dto";
import { SignUpDTO } from "../dtos/signup.dto";
import { UsersRepository } from "../repositories/user.repository";
@injectable()
export class AuthService {
constructor(
@inject(HashingService) private readonly hashingService: HashingService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository
) {}
async login(data: LoginDTO) {
try {
const user = await this.usersRepository.findOneByEmail(data.email);
if (!user) {
throw BadRequest("invalid-email");
}
const hashedPassword = await this.hashingService.verify(
user.password,
data.password
);
if (!hashedPassword) {
throw BadRequest("wrong-password");
}
return user;
} catch (e) {
if (e instanceof HTTPException) {
throw e;
}
throw InternalError("error-login");
}
}
async signup(data: SignUpDTO) {
try {
const existingEmail = await this.usersRepository.findOneByEmail(
data.email
);
if (existingEmail) {
throw BadRequest("email-already-in-use");
}
const hashedPassword = await this.hashingService.hash(data.password);
data.password = hashedPassword;
const newUser = await this.usersRepository.create(data);
return newUser;
} catch (e) {
if (e instanceof HTTPException) {
throw e;
}
throw InternalError("error-signup");
}
}
}
I know I introduced a lot of code, so let's break it down.
-
Login
- Check if an user at given email address is extisting, if not we throw error;
- Check the password that the user send in the request and the hash of the password stored in the database, be basically hash again the password in the request and check if the two hash are the same, if yes it means it is the correct password, otherwise it is the wrong password.
- then return the user.
-
Signup
- Check if email in the request is linked to some user, and exsist in the database;
- If exist a user with the email sended in the request, you can't proceed with the sign up since a user with this email already exist, and the email is a unique attribute for the user.
- If the email is not associated to any user, proceed with hashing the password in the request, set the hash as the password of the user;
- create the new user and return it.
Error Handling
With the introduction of the service, I of course introduce the error handling part.
As for the response, I want to keep it minimal. So I create some predefined wrappers
, so I can easy reference to them in my services:
// src/commons/error.ts
import { StatusCodes } from "./status-codes";
import { HTTPException } from "hono/http-exception";
export function TooManyRequests(message: string = "Too many requests") {
return new HTTPException(StatusCodes.TOO_MANY_REQUESTS, { message });
}
export function Forbidden(message: string = "Forbidden") {
return new HTTPException(StatusCodes.FORBIDDEN, { message });
}
export function Unauthorized(message: string = "Unauthorized") {
return new HTTPException(StatusCodes.UNAUTHORIZED, { message });
}
export function NotFound(message: string = "Not Found") {
return new HTTPException(StatusCodes.NOT_FOUND, { message });
}
export function BadRequest(message: string = "Bad Request") {
return new HTTPException(StatusCodes.BAD_REQUEST, { message });
}
export function InternalError(message: string = "Internal Error") {
return new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { message });
}
As you can see these function wraps the default HTTPException
that hono
give to us, the StatusCode
is an enum
that is stored in a separate file, I will not paste it here, since it is a quite big file, but you can find it by yourself here, you just need to paste it, and copy in the src/costants/status-codes.ts
file.
Hashing Service
Before diving into the Repository
I will first introduce the Hashing Service
, this is a very small service that is responsible to handle, as you can imagine, the hashing part of the application.
// src/services/hashing.service.ts
import { injectable } from "tsyringe";
import { Scrypt } from "oslo/password";
@injectable()
export class HashingService {
private readonly hasher = new Scrypt();
// private readonly hasher = new Argon2id(); // argon2id hasher
async hash(data: string) {
return this.hasher.hash(data);
}
async verify(hash: string, data: string) {
return this.hasher.verify(hash, data);
}
}
I use Scrpt
as the hashing algorithm due to its higher compatability and it uses less memory than Argon2id
.
You can use Argon2id
or any other hashing algorithm you prefer, it will be the same as you can see in the comment line of code.
User Repository
As written in the introduction the repository
layer is responsible to handle the interaction with the db
and he does not know anything about the buisness logic or route handling.
It just write, delete, update and retrive data from the database.
Since DrizzleORM
offers also the possibility to perform transaction
in PostgreSQL
we will define a general shape for a Repository
.
// src/interfaces/repository.interface.ts
import type { DatabaseProvider } from "../providers/database.provider";
export interface Repository {
trxHost(trx: DatabaseProvider): any;
}
The interface is really simple, but as usual I introduce a new piece of the puzzle.
Database Provider
I do not introduced the provider
concept so far, so let me explain better.
This is how the DatabaseProvider
looks like.
// src/providers/database.provider.ts
import { container } from "tsyringe";
import { db } from "../infrastructure/database";
// Symbol
export const DatabaseProvider = Symbol("DATABASE_TOKEN");
// Type
export type DatabaseProvider = typeof db;
// Register
container.register<DatabaseProvider>(DatabaseProvider, { useValue: db });
As you can see I'm using the db
instance that I previously declared in the infrastructure part. In that way we ensure that we are using the same instance of db
across all the application, and we avoid the "error prone" approach of importing manually the db
inside each repository.
Indeed in this case tsyringe
provides us the same instance of the same object across all the application, thanks to Dependency Injection.
User Repository Implementation
In this project I will not make a separate distinction between the shape of the repository and the repository implementation it self, you follow this approach if you prefer ,but let's see how the user repository
is made up.
// src/repositories/user.repository.ts
import { inject, injectable } from "tsyringe";
import type { Repository } from "../interfaces/repository.interface";
import { eq, type InferInsertModel } from "drizzle-orm";
import { DatabaseProvider } from "../providers/database.provider";
import { usersTable } from "../infrastructure/database/tables";
import { takeFirstOrThrow } from "../infrastructure/database/utils";
import { BadRequest } from "../common/error";
export type CreateUser = InferInsertModel<typeof usersTable>;
export type UpdateUser = Partial<CreateUser>;
@injectable()
export class UsersRepository implements Repository {
constructor(@inject(DatabaseProvider) private db: DatabaseProvider) {}
async findAll() {
return this.db.query.usersTable.findMany();
}
async findOneById(id: string) {
return this.db.query.usersTable.findFirst({
where: eq(usersTable.id, id),
});
}
async findOneByIdOrThrow(id: string) {
const user = await this.findOneById(id);
if (!user) throw BadRequest("user-not-found");
return user;
}
async findOneByEmail(email: string) {
return this.db.query.usersTable.findFirst({
where: eq(usersTable.email, email),
});
}
async create(data: CreateUser) {
return this.db
.insert(usersTable)
.values(data)
.returning()
.then(takeFirstOrThrow);
}
async update(id: string, data: UpdateUser) {
return this.db
.update(usersTable)
.set(data)
.where(eq(usersTable.id, id))
.returning()
.then(takeFirstOrThrow);
}
trxHost(trx: DatabaseProvider) {
return new UsersRepository(trx);
}
}
Here we are using a lot of things that we mention in the previous paragraphs, like the takeFirstOrThrow
function, the DatabaseProvider
the super helpfull InferInsertModel
from drizzle-zod
, so I will not go in details of these, as I wont go in details of the drizzle
query creation, since it really depends on your ORM that of course could possibly be not Drizzle.
Starting Docker and Testing
So far we actually implement a very basic email and password system we will go in deep with the other features (sessions, refreshToken, email validation, etc...) later on in the article, I really wanna go step-by-step so you can fully understand the whole process.
For testing out what you made so far you can use Postman
or httpie
or what you prefer to test your endpoint.
But first you need to start the whole system.
- Generate the migration:
drizzle-kit generate
, - Push the changes:
drizzle-kit push
, - Start Docker:
docker-compose up -d --build
For the for the sake of simplicity I will just put those script inside my package.json
:
"scripts": {
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio --verbose",
"dev": "bun run --hot src/index.ts",
"initialize": "bun install && docker-compose up --no-recreate -d && bun db:migrate",
"docker:up": "docker-compose up -d",
"docker:build": "docker-compose up -d --build"
},
I just add some self explanatory scripts.
For spin up for the first time the system, just run:
bun db:generate
To generate the SQL
file for the migration and:
bun initialize
To spin up everything, install dependencies, start docker and do the migration.
Once everything is done correctly you can move on with testing out the endpoints.
The json
body of the POST
request for the signup will be something like that:
{
"email": "email@example.com",
"password": `test1234`,
"passwordConfirmation": "test1234"
}
And the json
body of the POST
request for the login will be something like that:
{
"email": "email@example.com",
"password": `test1234`
}
The url can vary depends on what you put in the controller, in the base path, your port, ecc..
In my case I just need to query http:
JWT Access and Refresh Token System
Before deep diving into the implementation of the system, let me explain what a Refresh Token system is and how it works.
What is it?
In the refresh token system, there are two different tokens refreshToken
and accessToken
.
- The
accessToken
is a short-live token (usually from couple of minutes up to few days), it is the token that is in theAuthorization
field of thehttp request
from the client. This token is being validated each times we put a specialmiddleware
in the route (we will see it later on), it is not validated, the server will respond with a401 Unauthorized
.
How it works?
So far it seems like a normal JWT
authentication in wich the user ,when the accessToken
is expired, logs out even if he use the application and send request every day (very bad UX).
- In the
refreshToken
system we ensure that if the user keeps using the application, and keeps sending request to the server it will always get fresh newaccessToken
when it expires, so we the user will not logs out. - For doing that we actually have the needs of a new token, the
refreshToken
, this is usually a long-live token (usually a month or so), that the client store in a persistent database, cache or shared preferences. - The server does not have to store it, but it can be usefull if you wanna for example logs out a user if something bad happen, or if you wanna log out only certain devices (if you store the device ID), we will not cover this section in this article, but we will still store in the database the
refreshToken
. - This token is responsible to handle the case in wich the user request the access to a protected routes with an expired
accessToken
, in this case therefreshToken
"tells" the server that this user (who own therefreshToken
) have the right to access the resource even if hisaccessToken
, because hisrefreshToken
is still valid. - The whole process is handled both from the client and the server, the flow is the following.
- The user request the access to a protected resource with an expired
accessToken
, - The server respond with a
401 Unathorized
- The client intercept this error from the server, and try to send a
POST
request to the/refresh-token
endpoint in the server, with his own refresh token that he's is storing in some memory or disk. - The server recive the
POST
request within the clientrefreshToken
in the body. - If the
refreshToken
is still valid, and it is not expired, the server will proceed to generate fresh newaccessToken
and newrefreshToken
(and store it in disk) and send it back to the client. - The client, if everything so far goes well, will fetch the response, stores the new
accessToken
andrefreshToken
from the server, and retry the previous request, with the updatedaccessToken
, otherwise it will presumably log out the user.
- The user request the access to a protected resource with an expired
As you can see there is a lot of client-server communication, and I will cover only the server parts, because the client implementation can be various depends on your platform and technology (web, mobile, etc...).
Defining Routes
The first thing we need is to specify the endpoint where the user can send the POST
request to ask for a new refresh
and accessToken
.
We can put in the AuthController
since it is part of the domain of the "authentication".
As explained before, the user has to send his stored refresh token (the one in the client) to be able to request a new one. I will not separate the DTO
for this route, since it is only a string.
// src/controllers/auth.controlle.ts
import { Hono } from "hono";
import type { HonoTypes } from "../types";
import { injectable } from "tsyringe";
import { zValidator } from "@hono/zod-validator";
import { UserService } from "../services/user.service";
import { AuthService } from "../services/auth.service";
import { RefreshTokenService } from "../services/refresh-token.service";
import { loginDto } from "./../../../dtos/login.dto";
import { limiter } from "../middleware/rate-limiter.middlware";
import { z } from "zod":
@injectable()
export class AuthController implements Controller {
controller = new Hono<HonoTypes>();
constructor(){
@inject(AuthService) private readonly authService: AuthService,
@inject(RefreshTokenService) private readonly refreshTokenService: RefreshTokenService,
}
routes(){
return this.controller
// login and signup routes...
.post(
"/refresh-token",
zValidator("json", refreshTokenDTO),
limiter({ limit: 10, minutes: 60 }),
async (context) => {
const refreshTokenBody = context.req.valid("json");
const { accessToken, refreshToken } =
await this.refreshTokenService.refreshToken(
refreshTokenBody.refreshToken,
);
return context.json({ accessToken, refreshToken });
},
);
}
}
Refresh Token DTO
The Refresh Token DTO is straight forward, I wont spend more time on it.
// src/dtos/refresh-token.dto.ts
import { z } from "zod";
export const refreshTokenDTO = z.object({
refreshToken: z.string(),
});
export type RefreshTokenDTO = z.infer<typeof refreshTokenDTO>;
Refresh Token Service
As for the the other endpoints, we do not want to manage the business logic inside the controller, so we create a separate class for it. Create the refresh-token.service.ts
under the service
folder.
// src/services/refresh-token.service.ts
import { inject, injectable } from "tsyringe";
import { sign, verify } from "hono/jwt";
import { config } from "../common/config";
import { RefreshTokenRepository } from "../repositories/refresh-token.repository";
import { BadRequest, Unauthorized } from "../common/errors";
@injectable()
export class RefreshTokenService {
constructor(
@inject(RefreshTokenRepository) private readonly refreshTokenRepository: RefreshTokenRepository,
) {}
// Update refresh token by generating a new one and invalidating the old one
async refreshToken(refreshToken: string) {
const session =
await this.refreshTokenRepository.getSessionByToken(refreshToken);
if (!session || session.expiresAt < new Date()) {
throw BadRequest("invalid-refresh-token");
}
const newAccessToken = await this.generateAccessToken(session.userId);
const newRefreshToken = await this.generateRefreshToken(session.userId);
await this.refreshTokenRepository.updateRefreshToken(
session.userId,
refreshToken,
newRefreshToken,
config.jwt.refreshExpiresInDate
);
return { refreshToken: newRefreshToken, accessToken: newAccessToken };
}
async storeSession(userId: string, refreshToken: string) {
await this.refreshTokenRepository.storeRefreshToken(
userId,
refreshToken,
config.jwt.refreshExpiresInDate
);
}
async generateRefreshToken(userId: string){
const payload = {
sub: userId,
exp: config.jwt.refreshExpiresIn,
};
const refreshToken = await sign(payload, config.jwt.refreshSecret));
return refreshToken;
}
async generateAccessToken(userId: string): Promise<string> {
const payload = {
sub: userId,
exp: config.jwt.accessExpiresIn,
};
const accessToken = await sign(payload, config.jwt.accessSecret);
return accessToken;
}
async removeRefreshToken(refreshToken: string){
await this.refreshTokenRepository.removeRefreshToken(refreshToken);
}
async invalidateUserSessions(userId: string) {
await this.refreshTokenRepository.invalidateAllTokensForUser(userId);
}
}
As usual I will break down the code in different steps.
-
Refresh Token Function
- The refresh token function is responsible to retrieve, if exist in the database, a session based on the given refresh token in the request.
- If it does not exist or it is expired, the function will
throw
an error. - Otherwise it will generate both
accessToken
andrefreshToken
and it wills store them in the database.- Both the tokens are generated via the
hono/jwt
library, thathono
provides us. - They are generated based on the configuration parameters.
- Both the tokens are generated via the
- Finally return both the tokens
-
JWT Secrets I introduced new secrets that need to be added to the
.env
file.
JWT_ACCESS_SECRET="your_access_secret"
JWT_REFRESH_SECRET="your_refresh_secret"
Now the config.type.ts
file where there are all the types
of the configuration need to be updated too.
// 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 ?? "",
},
jwt: {
accessSecret: process.env.JWT_ACCESS_SECRET || "your_access_secret",
refreshSecret: process.env.JWT_REFRESH_SECRET || "your_refresh_secret",
accessExpiresIn: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, // 7 days
refreshExpiresIn: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30, // 30 days
refreshExpiresInDate: new Date(Date.now() + 60 * 60 * 24 * 30 * 1000), // 30 days
},
});
// the rest of the configuration ...
interface Config {
isProduction: boolean;
api: ApiConfig;
postgres: PostgresConfig;
jwt: JwtConfig;
}
interface JwtConfig {
accessSecret: string;
refreshSecret: string;
accessExpiresIn: number;
refreshExpiresIn: number;
refreshExpiresInDate: Date;
}
Now it is up to you to choose the expiration dates of the accessToken
and refreshToken
, as well as the secrets.
Refresh Token Repository
Also this service requires a repository since the sessions are stored in the persistent database as discussed previously.
// src/repositories/refres-token.repository.ts
import { inject, injectable } from "tsyringe";
import { DatabaseProvider } from "../providers";
import { eq } from "drizzle-orm";
import { sessionsTable } from "../tables";
import { and } from "drizzle-orm/expressions";
import { takeFirstOrThrow } from "../infrastructure/database/utils";
import { config } from "../common/config";
@injectable()
export class RefreshTokenRepository {
constructor(@inject(DatabaseProvider) private db: DatabaseProvider) {}
async storeRefreshToken(userId: string, token: string, expiresAt: Date) {
return this.db
.insert(sessionsTable)
.values({ userId, token, expiresAt })
.returning()
.then(takeFirstOrThrow);
}
async removeRefreshToken(token: string) {
return this.db.delete(sessionsTable).where(eq(sessionsTable.token, token));
}
async getSessionByToken(refreshToken: string) {
return this.db.query.sessionsTable.findFirst({
where: eq(sessionsTable.token, refreshToken),
});
}
async updateRefreshToken(
userId: string,
oldToken: string,
newToken: string,
expiresAt: Date
) {
const body = { userId, token: newToken, expiresAt };
await this.db.transaction(async (trx) => {
await trx.delete(sessionsTable).where(eq(sessionsTable.token, oldToken));
await trx.insert(sessionsTable).values(body);
});
}
async invalidateAllTokensForUser(userId: string) {
return this.db
.delete(sessionsTable)
.where(eq(sessionsTable.userId, userId));
}
}
In this service the Database Provider
that has been declared in the past is needed since a db transaction
is needed. For a reference of what is a transaction I will leave the official definition in the postgres documentation:
Transactions are a fundamental concept of all database systems. The essential point of a transaction is that it bundles multiple steps into a single, all-or-nothing operation. The intermediate states between the steps are not visible to other concurrent transactions, and if some failure occurs that prevents the transaction from completing, then none of the steps ?affect the database at all.
Sessions Table
The session table introduced in the repository contains all the sessions of the users.
It will be located under the src/infrastructure/database/tables/
folder:
// src/infrastructure/database/tables/sessions.table.ts
import { cuid2 } from "./utils";
import { usersTable } from "./users.table";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
export const sessionsTable = pgTable("sessions", {
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
token: text("token").notNull(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at", {
withTimezone: true,
mode: "date",
}).notNull(),
});
export const sessionRelationships = relations(
sessionsTable,
({ many, one }) => ({
users: one(usersTable, {
fields: [sessionsTable.userId],
references: [usersTable.id],
}),
})
);
Since we added the relationship in the sessionsTable
we have to do the same for the usersTable
// src/infrastructure/database/tables/users.table.ts
import { relations } from "drizzle-orm";
import { sessionsTable } from "./sessions.table";
// rest of the imports ...
export const usersTable = ... // table declaration
//rest of the code and table definitions ...
export const usersRelations = relations(usersTable, ({ many, one }) => ({
sessions: many(sessionsTable),}));
[ ⚠️⚠️⚠️ ] Do not forget to generate the migration and push it once you have done with the editing of your schema [ ⚠️⚠️⚠️ ]
Authentication Middleware
Once defined the routes, the logic and the database integration of the JWT refresh token system, we need to find a way to actually prevent the access to certain resources.
In hono
, and other frameworks too, offers the possibility of defining middleware
as we did before for the limiter
and zValidator()
.
Let's define one for protecting only certain routes.
// src/middleware/auth.middleware.ts
import type { MiddlewareHandler } from "hono";
import { createMiddleware } from "hono/factory";
import { verify } from "hono/jwt";
import { Unauthorized } from "../common/error";
import { config } from "../types/config.type";
export const validateAuthSession: MiddlewareHandler = async (c, next) => {
const authHeader = c.req.header("Authorization") ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
if (!token) {
c.set("userId", null);
return next();
}
try {
// Verify the access token
const payload = await verify(token, config.jwt.accessSecret);
const userId = payload.sub as string;
if (!userId) {
c.set("userId", null);
return next();
}
// Set user id in context
c.set("userId", userId);
} catch (error) {
// If token verification fails, set user to null
c.set("user", null);
}
return next();
};
export const requireAuth: MiddlewareHandler<{
Variables: {
userId: string;
};
}> = createMiddleware(async (c, next) => {
const user = c.var.userId;
if (!user)
throw Unauthorized("You must be logged in to access this resource");
return next();
});
As you can see I come up with 2 different middlewares, this is why I will use the validateAuthSession
globally, so at each request, the JWT
will be validated. And only where needed the requireAuth
middleware is placed next to the route to protect it.
To define a global middleware just add this two lines in the index.ts
:
// src/index.ts
import { validateAuthSession } from "./server/api/middleware/auth.middleware";
/* --------------------------- Global Middlewares --------------------------- */
app.use("*", cors({ origin: "*" })); // Allow CORS for all origins
app.use(validateAuthSession);
app.use(logger()); // [OPTIONAL] if you wanna log all the request and response, the import is import { logger } from "hono/logger";
and if you wanna protect a route, just use the requireAuth
middleware on the route:
.post(
"/protected-resource",
requireAuth,
async (context) => {
return context.text("Access acquired to the protected resource");
},
);
Update Login Flow
Since now handle our sessions with JWT we need to find a way to send back to the client his accessToken
and refreshToken
so he can e authenticated and can access all the protected resources.
For doing that we take advantage of the RefreshTokenService
previously created, since this is part of the business logic we're gonna handle it in the AuthService
.
// src/services/auth.service.ts
// Rest of the imports...
import { RefreshTokenService } from "./refresh-token.service";
@injectable()
export class AuthService {
constructor(
@inject(HashingService) private readonly hashingService: HashingService,
@inject(RefreshTokenService) private readonly refreshTokenService: RefreshTokenService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
) {}
async login(data: LoginDTO) {
try {
const user = await this.usersRepository.findOneByEmail(data.email);
if (!user) {
throw BadRequest("invalid-email");
}
const hashedPassword = await this.hashingService.verify(
user.password,
data.password,
);
if (!hashedPassword) {
throw BadRequest("wrong-password");
}
//if everything is good, create refresh token and access token
const accessToken = await this.refreshTokenService.generateAccessToken(
user.id,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
);
//store the refresh token session in the database
await this.refreshTokenService.storeSession(user.id, refreshToken);
return { user, accessToken, refreshToken };
} catch (e) {
if (e instanceof HTTPException) {
throw e;
}
throw InternalError("error-login");
}
}
// Rest of the code ...
Now let's reflect the changes in the AuthController
.
// src/controllers/auth.controllers.ts
// Rest of the code...
.post(
"/login",
zValidator("json", loginDTO),
limiter({ limit: 10, minutes: 60 }),
async (context) => {
const body = context.req.valid("json");
const { user, accessToken, refreshToken } =
await this.authService.login(body);
return context.json({ user, accessToken, refreshToken });
},
)
// Rest of the code...
Testing the endpoints
Now what you can do is to try is to perform a login as we seen in the last chapter.
You should receive an output like this when you login:
{
"user": {
"id": "mdh2e14meythwnvupsbxez2r",
"email": "test@test.com",
"password": "5b21310d820a9f0099c6e7fc8eefac3a:28160978a8833f334bd2c92f732b7572c73e5045189cf3b3efc39f45a1bab9b56b4dc23235895b304e46e4bbb6913238f3a79de5ef225d68f399baf76f36f062",
"createdAt": "2024-10-27T15:31:10.420Z",
"updatedAt": "2024-10-27T15:31:10.420Z"
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZGgyZTE0bWV5dGh3bnZ1cHNieGV6MnIiLCJleHAiOjE3MzA3NDA5MzF9.-O2kj5Yf1c-PeAp2oDnlYYQZPmLdEG0b9TCFW_z_Yok",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZGgyZTE0bWV5dGh3bnZ1cHNieGV6MnIiLCJleHAiOjE3MzI3MjgxMzF9.8DYcg6-y72tNmt6guYt5ePvoGiR1ZJto3M8J4ogVR14"
}
Now you can perform a POST
request to the .../api/auth/refresh-token
According to our implementation the body should look like this.
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZGgyZTE0bWV5dGh3bnZ1cHNieGV6MnIiLCJleHAiOjE3MzI3MjgxMzF9.8DYcg6-y72tNmt6guYt5ePvoGiR1ZJto3M8J4ogVR14"
}
and you should get something like this
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZGgyZTE0bWV5dGh3bnZ1cHNieGV6MnIiLCJleHAiOjE3MzA3NDA5MzF9.-O2kj5Yf1c-PeAp2oDnlYYQZPmLdEG0b9TCFW_z_Yok",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZGgyZTE0bWV5dGh3bnZ1cHNieGV6MnIiLCJleHAiOjE3MzI3MjgxMzF9.8DYcg6-y72tNmt6guYt5ePvoGiR1ZJto3M8J4ogVR14"
}
Two brand new accessToken
and refreshToken
.
Email Verification
One thing necessary for a robust authentication is an email verification system, so the users cannot use incorrect or random email address to gather access at your content.
The general flow of an email verification system is the following:
- Once the user signup he will receive an email containing the instruction to activate his account (so verify his email), the possible ways at this point are more or less two:
- Verify the user by making it click on a link. In this case the user will basically make a
GET
osPOST
request to a particular endpoint with some information in the query to verify himself. - Verify the user by sending him a verification code to enter after the registration in the client.
- Verify the user by making it click on a link. In this case the user will basically make a
- Those two steps are similar but the final result is the same, the UX (for the client) will change a bit, in terms of server the system is basically identical, you will figure out how to switch between the two by your self.
Defining Routes
We needs just one route for this purpose, since there will be only one endpoint for the email validation.
// src/controllers/auth.controller.ts
// All the imports...
@injectable()
export class AuthController implements Controller {
controller = new Hono<HonoTypes>();
constructor(){
@inject(AuthService) private readonly authService: AuthService,
@inject(RefreshTokenService) private readonly refreshTokenService: RefreshTokenService,
@inject(EmailVerificationsService) private readonly emailVerificationsService: EmailVerificationsService,
}
routes(){
return this.controller
// login, signup and refresh token routes...
.get(
"/verify/:userId/:token",
limiter({ limit: 10, minutes: 60 }),
async (context) => {
const { userId, token } = context.req.param();
await this.emailVerificationsService.processEmailVerificationRequest(
userId,
token,
);
// display or return something to the user
return context.html(`<h1>Email verified!</h1>`)
},
);
}
}
The returning object really depends on your client, I just put a generic title here because in my implementation the user is supposed click on a link and whenever he gets on this web page, a GET
request will be made, and he will be validated (if token
and userId
are valid) .
Email Verification Service
The process here is pretty straightforward.
- Whenever a new user is created (
signup
) the service will process this request. - It creates a new token, his hash (since we store the hash of the token, not the token itself), and the expiry.
- A new record in the
emailVerificationsTable
is created with the email that request the verification, theuserId
(the ones created in the signup), the hashed token, and the expiry. - An email is sent to the email that request the verification.
That is when a new user is being created, let's see when the user request the actual verification.
- If a record in the
emailVerificaitonsTable
with theuserId
(the one in the param of the request) is found, the hased token is compared with thetoken
in the params. - If everything goes okay, the email verification record will be deleted.
- Finally the
verified
status inside the users record will be updated.
Let's implement what I just said.
// src/services/email-verification.service.ts
import { inject, injectable } from "tsyringe";
import { DatabaseProvider } from "../providers/database.provider";
import { HashingService } from "./hashing.service";
import { UsersRepository } from "../repositories/user.repository";
import { TokensService } from "./token.service";
import { MailerService } from "./mailer.service";
import { EmailVerificationsRepository } from "../repositories/email-verifications.repository";
import { BadRequest } from "../common/error";
@injectable()
export class EmailVerificationsService {
constructor(
@inject(DatabaseProvider) private readonly db: DatabaseProvider,
@inject(TokensService) private readonly tokensService: TokensService,
@inject(MailerService) private readonly mailerService: MailerService,
@inject(HashingService) private readonly hashingService: HashingService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
@inject(EmailVerificationsRepository)
private readonly emailVerificationsRepository: EmailVerificationsRepository
) {}
async dispatchEmailVerificationRequest(
userId: string,
requestedEmail: string
) {
// generate a token and expiry
const { token, expiry, hashedToken } =
await this.tokensService.generateTokenWithExpiryAndHash({
number: 15,
time: 30,
lifespan: "m",
type: "STRING",
});
const user = await this.usersRepository.findOneByIdOrThrow(userId);
// create a new email verification record
await this.emailVerificationsRepository.create({
requestedEmail,
userId,
hashedToken,
expiresAt: expiry,
});
// A confirmation-required email message to the proposed new address, instructing the user to
// confirm the change and providing a link for unexpected situations
this.mailerService.sendEmailVerificationToken({
to: requestedEmail,
props: {
link: token,
},
});
}
async processEmailVerificationRequest(userId: string, token: string) {
const validRecord = await this.findAndBurnEmailVerificationToken(
userId,
token
);
if (!validRecord) throw BadRequest("invalid-token");
await this.usersRepository.update(userId, {
email: validRecord.requestedEmail,
verified: true,
});
}
private async findAndBurnEmailVerificationToken(
userId: string,
token: string
) {
return this.db.transaction(async (trx: any) => {
// find a valid record
const emailVerificationRecord = await this.emailVerificationsRepository
.trxHost(trx)
.findValidRecord(userId);
if (!emailVerificationRecord) return null;
// check if the token is valid
const isValidRecord = await this.hashingService.verify(
emailVerificationRecord.hashedToken,
token
);
if (!isValidRecord) return null;
// burn the token if it is valid
await this.emailVerificationsRepository
.trxHost(trx)
.deleteById(emailVerificationRecord.id);
return emailVerificationRecord;
});
}
}
This service involve the actions of other services, since we need to generate tokens, hash and verify them, we need to send email and so on. So since this two features are most likely required in other part of the application let's split them in separated services.
Token Service
// src/services/token.service.ts
import { inject, injectable } from "tsyringe";
import { generateRandomString } from "oslo/crypto";
import { TimeSpan, createDate, type TimeSpanUnit } from "oslo";
import { HashingService } from "./hashing.service";
@injectable()
export class TokensService {
constructor(
@inject(HashingService) private readonly hashingService: HashingService
) {}
generateNumberToken(number: number) {
const alphabet = "1234567890";
return generateRandomString(number, alphabet);
}
generateStringToken(number: number) {
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVZ";
return generateRandomString(number, alphabet);
}
generateTokenWithExpiry({
number,
time,
lifespan,
type,
}: {
number: number;
time: number;
lifespan: TimeSpanUnit;
type: "NUMBER" | "STRING";
}) {
return {
token:
type === "NUMBER"
? this.generateNumberToken(number)
: this.generateStringToken(number),
expiry: createDate(new TimeSpan(time, lifespan)),
};
}
async generateTokenWithExpiryAndHash({
number,
time,
lifespan,
type,
}: {
number: number;
time: number;
lifespan: TimeSpanUnit;
type: "NUMBER" | "STRING";
}) {
const token =
type === "NUMBER"
? this.generateNumberToken(number)
: this.generateStringToken(number);
const hashedToken = await this.hashingService.hash(token);
return {
token,
hashedToken,
expiry: createDate(new TimeSpan(time, lifespan)),
};
}
}
As you can see the token service
is pretty straightforward, it relies mainly on the hashing service
and it expose useful API to generate random and secure token.
Mailer Service
The mailer service is responsible to give the possibility to send email using html
as template language.
// src/services/mailer.service.ts
import fs from "fs";
import path from "path";
import handlebars from "handlebars";
import { injectable } from "tsyringe";
import nodemailer from "nodemailer";
type SendMail = {
to: string | string[];
subject: string;
html: string;
};
type SendTemplate<T> = {
to: string | string[];
props: T;
};
@injectable()
export class MailerService {
private nodemailer = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
secure: false, // Use `true` for port 465, `false` for all other ports
auth: {
user: "adella.hoppe@ethereal.email",
pass: "dshNQZYhATsdJ3ENke",
},
});
sendEmailVerificationToken(data: SendTemplate<{ link: string }>) {
const template = handlebars.compile(this.getTemplate("email-verification"));
return this.send({
to: data.to,
subject: "Email Verification",
html: template({ link: data.props.link }),
});
}
sendResetPasswordOTP(data: SendTemplate<{ otp: string }>) {
const template = handlebars.compile(this.getTemplate("reset-password"));
return this.send({
to: data.to,
subject: "Password Reset",
html: template({ code: data.props.otp }),
});
}
private async send({ to, subject, html }: SendMail) {
const message = await this.nodemailer.sendMail({
from: '"Example" <example@ethereal.email>', // sender address
bcc: to,
subject, // Subject line
text: html,
html,
});
}
private getTemplate(template: string) {
const __dirname = path.dirname(__filename); // get the name of the directory
return fs.readFileSync(
path.join(__dirname, `../infrastructure/email-templates/${template}.hbs`),
"utf-8"
);
}
}
Email Templates
Since this is not the main goal of the guide I will not dive into the email template part, but here you can find the templates that I have used in the guide.
Those files are .hbs
that are basically Handlebars file, that is a templating engine, you can find the documentation here
Handlebars is a simple templating language. It uses a template and an input object to generate HTML or other text formats. Handlebars templates look like regular text with embedded Handlebars expressions.
Email Verifications Repository
The email verifications
// src/repositories/email-verifications.repository.ts
import { inject, injectable } from "tsyringe";
import { DatabaseProvider } from "../providers";
import { and, eq, gte, lte, type InferInsertModel } from "drizzle-orm";
import type { Repository } from "../interfaces/repository.interface";
import { takeFirst, takeFirstOrThrow } from "../infrastructure/database/utils";
import { emailVerificationsTable } from "./../../../tables";
export type CreateEmailVerification = Pick<
InferInsertModel<typeof emailVerificationsTable>,
"requestedEmail" | "hashedToken" | "userId" | "expiresAt"
>;
@injectable()
export class EmailVerificationsRepository implements Repository {
constructor(
@inject(DatabaseProvider) private readonly db: DatabaseProvider
) {}
// creates a new email verification record or updates an existing one
async create(data: CreateEmailVerification) {
return this.db
.insert(emailVerificationsTable)
.values(data)
.onConflictDoUpdate({
target: emailVerificationsTable.userId,
set: data,
})
.returning()
.then(takeFirstOrThrow);
}
// finds a valid record by token and userId
async findValidRecord(userId: string) {
return this.db
.select()
.from(emailVerificationsTable)
.where(
and(
eq(emailVerificationsTable.userId, userId),
gte(emailVerificationsTable.expiresAt, new Date())
)
)
.then(takeFirst);
}
async deleteById(id: string) {
return this.db
.delete(emailVerificationsTable)
.where(eq(emailVerificationsTable.id, id));
}
trxHost(trx: DatabaseProvider) {
return new EmailVerificationsRepository(trx);
}
}
Email Verifications Table
The Email Verifications table will be used to store all the request of email verifications and also to store the tokens that we need to verify the email along the userId
and email
that performs the request.
import { createId } from "@paralleldrive/cuid2";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { usersTable } from "./users.table";
import { timestamps } from "./utils";
export const emailVerificationsTable = pgTable("email_verifications", {
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
hashedToken: text("hashed_token").notNull(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id)
.unique(),
requestedEmail: text("requested_email").notNull(),
expiresAt: timestamp("expires_at", {
mode: "date",
withTimezone: true,
}).notNull(),
...timestamps,
});
export const emailVerificationsRelations = relations(
emailVerificationsTable,
({ one }) => ({
user: one(usersTable, {
fields: [emailVerificationsTable.userId],
references: [usersTable.id],
}),
})
);
Users Table
Since we are verifying the user email, we have to find a way to mark the user as selected, for this purpose we are gonna need a boolean
field in the usersTable
.
// ... all the imports
export const usersTable = pgTable("users", {
id: text("id")
.primaryKey()
.$defaultFn(() => createId()),
email: citext("email").notNull().unique(),
password: text("password").notNull(),
verified: boolean("verified").notNull().default(false), // flag to add
...timestamps,
});
// ...the rest of the code
and also update the relations:
export const usersRelations = relations(usersTable, ({ many, one }) => ({
sessions: many(sessionsTable),
emailVerifications: one(emailVerificationsTable, {
fields: [usersTable.id],
references: [emailVerificationsTable.userId],
}),
}));
Forgot password, OTP code verification, Reset password
In addition to handling user authentication through access and refresh tokens, a robust authentication system must also provide mechanisms for users to recover access to their accounts. This is where the Forgot Password and Password Reset functionalities come into play. Let’s explore what these features entail and how they operate within the authentication flow.
What is it?
The Forgot Password and Password Reset system allows users to securely reset their passwords in case they forget them. This system typically involves the following steps:
- Forgot Password Request: Users initiate the password recovery process by providing their registered email address.
- OTP (One-Time Password) Verification: To ensure the request is legitimate, the system sends a one-time password (OTP) to the user's email, which they must verify.
- Password Reset: Once the OTP is validated, users can set a new password for their account.
These steps help maintain the security and integrity of user accounts by ensuring that only the rightful owner can reset the password.
How it works?
Implementing a secure password reset mechanism involves coordinated client-server interactions. Here’s an overview of the process:
-
Initiating Password Reset:
- The user navigates to the "Forgot Password" section and submits their registered email address.
- The client sends a
POST
request to the/forgotpassword
endpoint with the user's email. - The server generates a unique password reset token, stores a hashed version of it in the database, and sends the token to the user's email via the
MailerService
.
-
Verifying the OTP:
- Upon receiving the OTP in their email, the user enters it into the application.
- The client sends a
POST
request to the/verify-token
endpoint with the email and OTP. - The server validates the token by checking its existence, ensuring it hasn't expired, and verifying its correctness using the
HashingService
. - If the token is valid, the server responds with a success status, allowing the user to proceed to reset their password.
-
Resetting the Password: - The user enters a new password and confirms it. - The client sends a
POST
request to the/resetpassword/:token
endpoint, including the new password and the token in the URL. - The server verifies the token again to ensure it's still valid. - Upon successful verification, the server updates the user's password in the database using theHashingService
to securely hash the new password. - The server also invalidates any existing user sessions to prevent unauthorized access.
Defining routes
As usual we'll put the other routes inside the AuthController
since this also those topic are parts of the bigger domain of the authentication.
// src/controllers/auth.controller.ts
// All the imports...
@injectable()
export class AuthController implements Controller {
controller = new Hono<HonoTypes>();
constructor(){
@inject(AuthService) private readonly authService: AuthService,
@inject(RefreshTokenService) private readonly refreshTokenService: RefreshTokenService,
@inject(EmailVerificationsService) private readonly emailVerificationsService: EmailVerificationsService,
}
routes(){
return this.controller
// login, signup, refresh token and email verification routes...
.post(
"/forgotpassword",
zValidator("json", passwordResetEmailDTO),
limiter({ limit: 10, minutes: 60 }),
async (context) => {
const { email } = context.req.valid("json");
await this.passwordResetService.createPasswordResetToken({
email,
});
return context.json({ status: "success" });
}
)
.post(
"/verify-token",
zValidator("json", passwordResetEmailVerificationDTO),
limiter({ limit: 10, minutes: 60 }),
async (context) => {
const { email, token } = context.req.valid("json");
await this.passwordResetService.validateToken(token, email);
return context.json({ status: "success " });
}
)
.post(
"/resetpassword/:token",
zValidator("json", passwordResetDTO),
limiter({ limit: 10, minutes: 60 }),
async (context) => {
const body = context.req.valid("json");
const token = context.req.param("token");
await this.passwordResetService.resetPassword(token, body);
return context.json({ status: "success" });
}
);
}
}
The response I choose in this case are pretty basics, you can customize them as you want.
Password Reset DTO
This is the definitions of the DTOs for the password reset.
- For the
passwordResetEmailDTO
in the body of the request we just need the email that perform the request. - in the
passwordResetEmailVerificationDTO
we need to pass both the email and the token, in this case theOTP
code. - lastly the
passwordResetDTO
is pretty much identical to the system used in thesingUpDTO
we just need to add the email.
Notice that in this case the client is passing the email at every request, this is due the fact that the server is not caching the email, but you can implement a system to do it if you want to avoid the client to pass the email at every request.
// src/dtos/password-reset.dto.ts
import { InferInsertModel } from "drizzle-orm";
import { z } from "zod";
import { passwordResetTable } from "../infrastructure/database/tables/password-reset.table";
export const passwordResetEmailDTO = z.object({
email: z.string().email(),
});
export const passwordResetEmailVerificationDTO = z.object({
email: z.string().email(),
token: z.string().min(6).max(6),
});
export const passwordResetDTO = z
.object({
newPassword: z
.string({ required_error: "required-password" })
.min(8, "password-too-short")
.max(32, "password-too-long"),
confirmNewPassword: z
.string({ required_error: "required-confirmation-password" })
.min(8, "password-too-short")
.max(32, "password-too-long"),
email: z.string().email(),
})
.refine((data) => data.newPassword === data.confirmNewPassword, {
message: "passwords-donot-match",
path: ["passwordConfirmation"],
});
export type ResetPasswordEmailDto = z.infer<typeof passwordResetEmailDTO>;
export type ResetEmailVerificationDTO = z.infer<
typeof passwordResetEmailVerificationDTO
>;
export type ResetPasswordDto = z.infer<typeof passwordResetDTO>;
export type CreatePasswordResetRecord = Pick<
InferInsertModel<typeof passwordResetTable>,
"hashedToken" | "email" | "expiresAt"
>;
Password Reset Service
import { inject, injectable } from "tsyringe";
import { MailerService } from "./mailer.service";
import { HTTPException } from "hono/http-exception";
import { isWithinExpirationDate } from "oslo";
import { HashingService } from "./hashing.service";
import { TokensService } from "./token.service";
import { UsersRepository } from "../repositories/user.repository";
import { PasswordResetRepository } from "../repositories/password-reset.repository";
import { BadRequest, InternalError } from "../common/error";
import {
ResetPasswordDTO,
ResetPasswordEmailDTO,
} from "../dtos/password-reset.dto";
@injectable()
export class PasswordResetService {
constructor(
@inject(HashingService) private readonly hashingService: HashingService,
@inject(TokensService) private readonly tokensService: TokensService,
@inject(MailerService) private readonly mailerService: MailerService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
@inject(PasswordResetRepository)
private readonly passwordResetRepository: PasswordResetRepository
) {}
async validateToken(token: string, email: string) {
try {
const record =
await this.passwordResetRepository.findValidRecordByEmail(email);
if (!record || !isWithinExpirationDate(record?.expiresAt)) {
throw BadRequest("invalid-or-expired-token");
}
const isValidToken = await this.hashingService.verify(
record?.hashedToken,
token
);
if (!isValidToken) {
throw BadRequest("invalid-or-expired-token");
}
return { status: "success" };
} catch (e) {
if (e instanceof HTTPException) {
throw e;
}
throw InternalError("error-veryfing-token");
}
}
async resetPassword(token: string, data: ResetPasswordDTO) {
try {
const record = await this.passwordResetRepository.findValidRecordByEmail(
data.email
);
if (!record || !isWithinExpirationDate(record?.expiresAt)) {
throw BadRequest("invalid-or-expired-token");
}
const isValidToken = await this.hashingService.verify(
record?.hashedToken,
token
);
if (!isValidToken) {
throw BadRequest("invalid-or-expired-token");
}
const user = await this.usersRepository.findOneByEmail(data.email);
if (!user) {
throw BadRequest("no-user-with-this-email");
}
if (data.newPassword !== data.confirmNewPassword) {
throw BadRequest("password-donot-match");
}
await this.passwordResetRepository.deleteById(record.id);
const hashedPassword = await this.hashingService.hash(data.newPassword);
await this.usersRepository.update(user.id, {
password: hashedPassword,
});
} catch (e) {
if (e instanceof HTTPException) {
throw e;
}
throw InternalError("error-resetting-password");
}
}
async createPasswordResetToken(data: ResetPasswordEmailDTO) {
try {
// generate a token, expiry and hash
const { token, expiry, hashedToken } =
await this.tokensService.generateTokenWithExpiryAndHash({
number: 6,
time: 15,
lifespan: "m",
type: "NUMBER",
});
const user = await this.usersRepository.findOneByEmail(data.email);
if (!user) {
throw BadRequest("no-user-with-this-email");
}
//if there is an existing record delete it
await this.findRecordAndDelete(user.id);
// create a new email verification record
await this.passwordResetRepository.create({
email: user.email,
hashedToken: hashedToken,
expiresAt: expiry,
});
this.mailerService.sendResetPasswordOTP({
to: user.email,
props: {
otp: token,
},
});
} catch (e) {
if (e instanceof HTTPException) {
throw e;
}
throw InternalError("error-creating-password-reset-token");
}
}
async findRecordAndDelete(email: string) {
const existingRecord =
await this.passwordResetRepository.findValidRecordByEmail(email);
if (existingRecord) {
await this.passwordResetRepository.deleteById(existingRecord.id);
}
}
}
Password Reset Repository
import { inject, injectable } from "tsyringe";
import { and, eq, gte, lte, type InferInsertModel } from "drizzle-orm";
import type { Repository } from "../interfaces/repository.interface";
import { takeFirst, takeFirstOrThrow } from "../infrastructure/database/utils";
import { DatabaseProvider } from "../providers/database.provider";
import { passwordResetTable } from "../infrastructure/database/tables/password-reset.table";
import { CreatePasswordResetRecord } from "../dtos/password-reset.dto";
@injectable()
export class PasswordResetRepository implements Repository {
constructor(
@inject(DatabaseProvider) private readonly db: DatabaseProvider
) {}
async create(data: CreatePasswordResetRecord) {
return this.db
.insert(passwordResetTable)
.values(data)
.onConflictDoUpdate({
target: passwordResetTable.email,
set: data,
})
.returning()
.then(takeFirstOrThrow);
}
async findValidRecordByEmail(email: string) {
return this.db
.select()
.from(passwordResetTable)
.where(
and(
eq(passwordResetTable.email, email),
gte(passwordResetTable.expiresAt, new Date())
)
)
.then(takeFirst);
}
async deleteById(id: string) {
return this.db
.delete(passwordResetTable)
.where(eq(passwordResetTable.id, id));
}
trxHost(trx: DatabaseProvider) {
return new PasswordResetRepository(trx);
}
}
Conclusion
Building an authentication system with Hono and Drizzle is an essential step towards securing your web application while ensuring smooth user management. From implementing basic email/password authentication to advanced features like JWT-based authentication and password reset flows, this guide covered the key building blocks required for a robust authentication mechanism.
By following this guide, you’ve learned how to create a secure environment with the following functionalities:
- User Registration: Secure sign-up and validation of user data.
- Login and Session Management: Efficient handling of JWT access and refresh tokens.
- Password Management: Secure password storage and recovery options.
- Email Verification: Ensuring that users verify their emails before full access.
- Forgot Password: Giving users an easy way to reset their passwords through OTP-based verification.
All of this is achieved with well-defined routes, Zod validation, and database interactions using Drizzle ORM. The integration with Docker ensures a smooth development and production environment.
This architecture provides a clean, scalable solution that can be expanded further as your application grows.
What do to now?
Now that you've set up the foundational authentication system, it's time to take the following steps to further enhance your application:
- Security Features:
- Two-Factor Authentication (2FA):
- Authy: A popular app for TOTP-based 2FA that you can integrate into your system for an additional security layer.
- Duo Security: A comprehensive 2FA solution for web and mobile apps with enterprise-grade features.
- User Experience:
- Social Logins:
- Auth0: A popular Identity-as-a-Service (IDaaS) provider that simplifies the integration of multiple social logins (Google, Facebook, GitHub, etc.) with easy-to-use SDKs.
- Okta: A powerful identity provider offering social login, SSO (Single Sign-On), and multi-factor authentication (MFA) with easy integration.
- Custom Email Templates:
- SendGrid: A robust email delivery service that offers customizable email templates and APIs to send transactional emails such as verification and password reset emails.
- Mailgun: Provides an API for sending emails with advanced analytics and email template management.
- Testing and Quality Assurance:
- Test Coverage:
- SonarCloud: A code quality and coverage analysis tool that integrates with your CI/CD pipeline and ensures your authentication system is thoroughly tested.
- Documentation:
- Swagger / OpenAPI:
- SwaggerHub: A platform for designing, documenting, and testing your REST APIs. It offers a comprehensive interface for documenting your authentication API, and it can generate client libraries for different languages.
- Redocly: Another popular OpenAPI-based API documentation tool with a more modern UI for end-users.
- Monitor and Maintain:
- Error Monitoring:
- Sentry: Provides real-time error tracking and performance monitoring, which can help you track down issues in your authentication routes (like invalid tokens or failed login attempts).
- Rollbar: Another tool for tracking runtime errors and sending notifications for any unhandled exceptions or performance issues.
- Logs and Monitoring:
- Datadog: Offers infrastructure monitoring, log management, and application performance monitoring (APM). You can track authentication issues, like slow login responses or downtime.
- Loggly: A logging tool for aggregating, searching, and analyzing logs. It's useful for tracking authentication-related logs, such as failed login attempts or token generation events.
- Scaling:
- Load Balancers:
- AWS Elastic Load Balancing (ELB): Automatically distributes incoming application traffic across multiple instances, which helps scale the authentication system as your traffic increases.
- Nginx: Can be used as a reverse proxy server for load balancing to distribute requests evenly across your application servers.
- User Feedback and Monitoring:
- New Relic: Provides full-stack observability, including user interaction and API monitoring. It helps you track how users interact with your authentication system and whether there are any bottlenecks.
- Hotjar: Allows you to understand user behavior through heatmaps and session recordings. It helps identify where users are getting stuck during login or registration, making it easier to improve the flow.
- CI/CD Integration:
- GitHub Actions: Can be used to automate testing, deployment, and continuous integration for your authentication system.
- CircleCI: Another CI/CD tool that allows you to automate your build, test, and deployment process for your authentication system.
By following these next steps, you can ensure your authentication system remains secure, user-friendly, and ready for scaling as your project grows. Keep iterating and improving based on feedback, and your users will have a secure and seamless experience.
Contributing
If you think you can add value to the project, please feel free to submit a pull request or open a issue.
Resources
The reference of the resources used in this guide.
- Architecture: Tofu Stack
- Framework HonoJs
- ORM: DrizzleORM
- Database: PostgreSQL
- Authentication Reference : The Copenhagen Book