When I started building an authentication app, I thought it would be easy—just ask for a password, right? Fast-forward to me battling bugs and TypeScript errors, learning about bcrypt and JWTs, and questioning my life choices. But guess what? I made it work! Now, I finally built something that not only works but also won’t get hacked by a 12-year-old in their basement, and I’m here to share how I did it in simple steps. Let’s dive in!
What’s this Authentication App About?
This app is like the bouncer at a party—it makes sure only the right people get in. It lets users sign up, log in, and keeps their data secure with hashed passwords and JSON Web Tokens (JWTs). Plus, it’s built with TypeScript, so everything is extra reliable.
The Routes: What Does This App Do?
POST /signup
New user? No problem! This route handles user registration, validating their details against a schema to ensure everything checks out.POST /login
Checks if a user exists and if their password matches. If all is good, it hands over a shiny JWT for secure access.GET /verify-email/:token
Verifies a user’s email using a unique token, ensuring only legitimate accounts are activated.POST /forgot-password
Forgot your password? This route lets users request a password reset by validating their email and sending a secure reset link.POST /reset-password/:token
Allows users to reset their password using the token they received in their email, ensuring the process is both secure and seamless.
Now Let's Begin
Step 1: Lets set up your project
run this cmds
mkdir typescript-authentication-tutorial
cd typescript-authentication-tutorial
npm init -y
Step 2: Lets install All The Packages We'll need
npm install bcryptjs cors dotenv express helmet jsonwebtoken mongoose morgan nodemailer zod && npm install -D @types/bcryptjs @types/cors @types/express @types/helmet @types/jsonwebtoken @types/mongoose @types/morgan @types/nodemailer ts-node-dev typescript
Here's a brief explanation of what all the Packages do
Dependencies
-
express
: The web framework for building APIs. -
bcryptjs
: Used to hash and compare passwords securely. -
cors
: Middleware to handle Cross-Origin Resource Sharing (CORS). -
dotenv
: Loads environment variables from a .env file. -
helmet
: Adds security headers to your API. -
jsonwebtoken
: For creating and verifying JSON Web Tokens (JWTs). -
mongoose
: The library to work with MongoDB databases. -
morgan
: Logs HTTP requests for debugging. -
nodemailer
: Sends emails (e.g., for password resets). -
zod
: Schema validation library for input validation.
DevDependencies
-
typescript
: TypeScript compiler to add static typing. -
ts-node-dev
: Runs TypeScript files directly with hot-reloading. -
@types/express
: Type definitions for Express. -
@types/bcryptjs
: Type definitions for bcryptjs. -
@types/cors
: Type definitions for CORS. -
@types/helmet
: Type definitions for Helmet. -
@types/jsonwebtoken
: Type definitions for JSON Web Tokens. -
@types/mongoose
: Type definitions for Mongoose. -
@types/morgan
: Type definitions for Morgan. -
@types/nodemailer
: Type definitions for Nodemailer.
Step 3: Configure TypeScript
Run The Command:
tsc --init
Step 4: Now The Folder Structure:
This command would create the folders and files in one go🫢🫢
mkdir -p src/{config,controllers,middleware,models,routes,services,templates,types,utils,validators} && \
touch src/config/config.ts \
src/controllers/user.controller.ts \
src/middleware/auth.ts \
src/middleware/validate.ts \
src/models/user.models.ts \
src/routes/user.routes.ts \
src/services/email.service.ts \
src/templates/resetPassword.html \
src/templates/verifyEmail.html \
src/types/user.types.ts \
src/utils/error.ts \
src/validators/user.validators.ts \
src/app.ts \
src/server.ts
Step 5: Let's configure our tsconfig.json
file and also our scripts
in package.json
in tsconfig.json
add
"rootDir": "./src",
"outDir": "./dist",
in package.json
change your scripts
too
"scripts": {
"start": "node dist/server.js",
"dev": "ts-node-dev src/server.ts",
"build": "tsc"
},
Now Let's Code
Lets start with our app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import { config } from './config/config';
import userRoutes from './routes/user.routes';
const app = express();
// Middleware
app.use(helmet());
app.use(cors());
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/users', userRoutes);
// Error handling middleware
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({
status: 'error',
message: 'Internal server error',
});
});
export default app;
Now server.ts
import mongoose from 'mongoose';
import { config } from './config/config';
import app from './app';
/**
* Starts the server by connecting to MongoDB and then listening on the specified port.
*
* @async
* @function startServer
* @throws Will throw an error if the server fails to start.
* @returns {Promise<void>} A promise that resolves when the server is successfully started.
*/
const startServer = async () => {
try {
await mongoose.connect(config.mongodb.uri);
console.log('Connected to MongoDB');
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
};
startServer();
Now our config.ts
in the config
folder
// src/config/config.ts
import dotenv from 'dotenv';
import path from 'path';
// Load environment variables from .env file
dotenv.config({ path: path.join(__dirname, '../../.env') });
export const config = {
port: process.env.PORT || 3000,
mongodb: {
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/mydatabase',
},
jwt: {
secret: process.env.JWT_SECRET || 'default-secret-key',
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
},
bcrypt: {
saltRounds: parseInt(process.env.SALT_ROUNDS || '10', 10),
},
email: {
host: process.env.EMAIL_HOST,
port: parseInt(process.env.EMAIL_PORT || '587', 10),
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
frontend: {
url: process.env.FRONTEND_URL,
},
} as const;
Now create a .env
file
You can just use this cmd:
touch .env
here is what would be in the file
# Server Configuration
PORT=your_server_port
# Database Configuration
MONGODB_URI=your_mongodb_connection_string
# JWT Configuration
JWT_SECRET=your_jwt_secret
JWT_EXPIRES_IN=your_jwt_expiry_time
# Security
SALT_ROUNDS=your_salt_rounds_value
# Email Service Configuration
EMAIL_HOST=your_email_host
EMAIL_PORT=your_email_port
EMAIL_USER=your_email_user
EMAIL_PASS=your_email_password
# Frontend Configuration
FRONTEND_URL=your_frontend_url
Now auth.ts
and validate.ts
in middleware
folder
auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config/config';
export interface AuthRequest extends Request {
userId?: string;
}
/**
* Middleware to authenticate a JWT token from the request headers.
*
* @param req - The request object, extended to include `userId` if authentication is successful.
* @param res - The response object.
* @param next - The next middleware function in the stack.
*
* @returns A response with status 401 if no token is provided, or status 403 if the token is invalid or expired.
*
* @remarks
* This middleware expects the JWT token to be provided in the `Authorization` header in the format `Bearer <token>`.
* If the token is valid, the `userId` from the token payload is attached to the request object.
*/
export const authenticateToken = (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
const authHeader = req.headers.authorization;
const token = authHeader?.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'Authentication token required' });
}
try {
const decoded = jwt.verify(token, config.jwt.secret) as { userId: string };
req.userId = decoded.userId;
next();
} catch (error) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
};
validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
/**
* Middleware to validate the request body against a given Zod schema.
*
* @param schema - The Zod schema to validate the request body against.
* @returns An Express middleware function that validates the request body.
*
* @throws Will respond with a 400 status code and a JSON error message if validation fails.
*/
export const validateRequest = (schema: ZodSchema) => {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
await schema.parseAsync(req.body);
next();
} catch (error) {
if (error instanceof Error) {
res.status(400).json({
status: 'error',
message: 'Validation failed',
errors: 'errors' in error ? (error as any).errors : [error.message],
});
return;
}
next(error);
}
};
};
Now Let's Do user.types.ts
in the types
folder
/**
* Interface representing a User.
*
* @interface IUser
* @property {string} _id - Unique identifier for the user.
* @property {string} name - Name of the user.
* @property {string} email - Email address of the user.
* @property {string} password - Password of the user.
* @property {Boolean} isVerified - Indicates if the user's email is verified.
* @property {string} [resetPasswordToken] - Token used for resetting the password (optional).
* @property {Date} [resetPasswordExpires] - Expiry date for the reset password token (optional).
* @property {string} [verificationToken] - Token used for email verification (optional).
* @property {Date} [verificationTokenExpires] - Expiry date for the verification token (optional).
* @property {Date} createdAt - Date when the user was created.
* @property {Date} updatedAt - Date when the user was last updated.
*/
export interface IUser {
_id: string;
name: string;
email: string;
password: string;
isVerified: Boolean;
resetPasswordToken?: string;
resetPasswordExpires?: Date;
verificationToken?: string;
verificationTokenExpires?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface IUserInput {
name: string;
email: string;
password: string;
}
Now error.ts
in the utils
folder
import { Response } from 'express';
export class CustomError extends Error {
public statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
// Set the prototype explicitly to fix instanceof checks
Object.setPrototypeOf(this, CustomError.prototype);
}
}
export const createCustomError = (message: string, statusCode: number): CustomError => {
return new CustomError(message, statusCode);
};
/**
* Handles errors in controller functions and sends appropriate HTTP responses.
*
* @param error - The error object that was thrown.
* @param res - The Express response object.
* @returns The HTTP response with the appropriate status code and error message.
*
* If the error is an instance of `CustomError`, it sends a response with the status code and message from the error.
* Otherwise, it logs the error to the console and sends a 500 Internal Server Error response.
*/
export const handleControllerError = (error: unknown, res: Response): Response => {
if (error instanceof CustomError) {
return res.status(error.statusCode).json({
status: 'error',
message: error.message,
});
}
console.error('Unexpected error:', error);
return res.status(500).json({
status: 'error',
message: 'Internal server error',
});
};
Now user.validator.ts
in validators
folder
import { z } from 'zod';
/**
* Schema for validating user signup data.
*
* This schema ensures that the user provides:
* - A name that is a string with a minimum length of 2 and a maximum length of 50.
* - An email that is a valid email address.
* - A password that is a string with a minimum length of 8 and a maximum length of 100.
*/
export const signupSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
password: z.string().min(8).max(100),
});
export const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export const resetPasswordSchema = z.object({
password: z.string().min(8).max(100),
});
export const forgotPasswordSchema = z.object({
email: z.string().email(),
});
Now lets setup Our email.service.ts
in the services
folder
import nodemailer from 'nodemailer';
import fs from 'fs/promises';
import path from 'path';
import { config } from '../config/config';
export class EmailService {
// Create a transporter object using the default SMTP transport
private static transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: config.email.user,
pass: config.email.pass,
},
debug: true,
logger: true
});
// Read and return the email template content from the specified file
private static async getTemplate(templateName: string): Promise<string> {
const templatePath = path.join(__dirname, '../templates', `${templateName}.html`);
return await fs.readFile(templatePath, 'utf-8');
}
// Replace variables in the template with actual values
private static replaceTemplateVariables(template: string, variables: Record<string, string>): string {
return Object.entries(variables).reduce(
(acc, [key, value]) => acc.replace(new RegExp(`{{${key}}}`, 'g'), value),
template
);
}
// Verify the SMTP connection
static async verifyConnection(): Promise<boolean> {
try {
await this.transporter.verify();
console.log('SMTP connection verified successfully');
return true;
} catch (error) {
console.error('SMTP connection verification failed:', error);
return false;
}
}
// Send a verification email to the specified recipient
static async sendVerificationEmail(
to: string,
name: string,
verificationToken: string
): Promise<void> {
try {
const template = await this.getTemplate('verifyEmail');
const verificationLink = `${config.frontend.url}/verify-email?token=${verificationToken}`;
const html = this.replaceTemplateVariables(template, {
name,
verificationLink,
});
const mailOptions = {
from: `"FredAbod" <${config.email.user}>`,
to,
subject: 'Verify Your Email',
html,
};
const info = await this.transporter.sendMail(mailOptions);
console.log('Verification email sent successfully:', info.messageId);
} catch (error) {
console.error('Error sending verification email:', error);
throw new Error('Failed to send verification email');
}
}
// Send a password reset email to the specified recipient
static async sendPasswordResetEmail(
to: string,
name: string,
resetToken: string
): Promise<void> {
try {
const template = await this.getTemplate('resetPassword');
const resetLink = `${config.frontend.url}/reset-password?token=${resetToken}`;
const html = this.replaceTemplateVariables(template, {
name,
resetLink,
});
const mailOptions = {
from: `"FredAbod" <${config.email.user}>`,
to,
subject: 'Reset Your Password',
html,
};
const info = await this.transporter.sendMail(mailOptions);
console.log('Password reset email sent successfully:', info.messageId);
} catch (error) {
console.error('Error sending password reset email:', error);
throw new Error('Failed to send password reset email');
}
}
}
Lets Setup the templates we'll need
resetPassword.html
<!DOCTYPE html>
<html>
<head>
<style>
.email-container {
max-width: 600px;
margin: 0 auto;
font-family: Arial, sans-serif;
padding: 20px;
}
.button {
background-color: #4CAF50;
border: none;
color: white;
padding: 15px 32px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="email-container">
<h2>Reset Your Password</h2>
<p>Hello {{name}},</p>
<p>We received a request to reset your password. Click the button below to create a new password:</p>
<a href="{{resetLink}}" class="button">Reset Password</a>
<p>If you didn't request this, you can safely ignore this email. Your password will remain unchanged.</p>
<p>This link will expire in 1 hour.</p>
</div>
</body>
</html>
verifyEmail.html
<html>
<head>
<style>
.email-container {
max-width: 600px;
margin: 0 auto;
font-family: Arial, sans-serif;
padding: 20px;
}
.button {
background-color: #4CAF50;
border: none;
color: white;
padding: 15px 32px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
margin: 4px 2px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="email-container">
<h2>Verify Your Email</h2>
<p>Hello {{name}},</p>
<p>Thank you for registering! Please click the button below to verify your email address:</p>
<a href="{{verificationLink}}" class="button">Verify Email</a>
<p>If you didn't create an account, you can safely ignore this email.</p>
</div>
</body>
</html>
Now let's setup our database model
user.Models.ts
import mongoose, { Schema } from 'mongoose';
import { IUser } from '../types/user.types';
const userSchema = new Schema<IUser>(
{
name: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
index: true
},
password: {
type: String,
required: true
},
resetPasswordToken: String,
resetPasswordExpires: Date,
isVerified: {
type: Boolean,
default: false,
},
verificationToken: String,
verificationTokenExpires: Date,
},
{
timestamps: true
}
);
export const User = mongoose.model<IUser>('User', userSchema);
Now to the main logic the user.controller.ts
import { Request, Response } from "express";
import { User } from "../models/user.Models";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { config } from "../config/config";
import { IUserInput } from "../types/user.types";
import { createCustomError } from "../utils/error";
import { EmailService } from "../services/email.service";
import crypto from "crypto";
export class UserController {
// User signup method
static async signup(req: Request, res: Response): Promise<void> {
try {
const { name, email, password }: IUserInput = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
res.status(400).json({
status: 'error',
message: 'User already exists',
});
return;
}
// Verify SMTP connection
const isEmailServiceWorking = await EmailService.verifyConnection();
if (!isEmailServiceWorking) {
res.status(500).json({
status: 'error',
message: 'Email service is not available. Please try again later.',
});
return;
}
// Generate verification token and hash password
const verificationToken = crypto.randomBytes(32).toString('hex');
const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds);
// Create new user
const user = await User.create({
name,
email,
password: hashedPassword,
verificationToken,
verificationTokenExpires: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
});
try {
// Send verification email
await EmailService.sendVerificationEmail(email, name, verificationToken);
res.status(201).json({
status: 'success',
message: 'Registration successful. Please check your email to verify your account.',
});
} catch (emailError) {
// If email fails, mark user as requiring email verification retry
console.error('Failed to send verification email:', emailError);
await User.findByIdAndUpdate(user._id, {
$set: {
emailVerificationFailed: true
}
});
res.status(201).json({
status: 'warning',
message: 'Account created but verification email could not be sent. Please contact support.',
userId: user._id
});
}
} catch (error) {
console.error('Signup error:', error);
res.status(500).json({
status: 'error',
message: 'Internal server error',
});
}
}
// User login method
static async login(req: Request, res: Response): Promise<void> {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({ email });
if (!user) {
res.status(401).json({
status: "error",
message: "Invalid credentials",
});
return;
}
// Check if user is verified
if (user.isVerified != true) {
res.status(401).json({
status: "error",
message: "Verify Email",
});
return;
}
// Validate password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
res.status(401).json({
status: "error",
message: "Invalid credentials",
});
return;
}
// Generate JWT token
const token = jwt.sign({ userId: user._id }, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
});
res.json({
status: "success",
data: {
token,
user: {
id: user._id,
name: user.name,
email: user.email,
},
},
});
} catch (error) {
res.status(500).json({
status: "error",
message: "Internal server error",
});
}
}
// Email verification method
static async verifyEmail(req: Request, res: Response): Promise<void> {
try {
const { token } = req.params;
// Find user by verification token
const user = await User.findOne({
verificationToken: token,
verificationTokenExpires: { $gt: new Date() },
});
if (!user) {
res.status(400).json({
status: "error",
message: "Invalid or expired verification token",
});
return;
}
// Mark user as verified
user.isVerified = true;
user.verificationToken = undefined;
user.verificationTokenExpires = undefined;
await user.save();
res.json({
status: "success",
message: "Email verified successfully",
});
} catch (error) {
res.status(500).json({
status: "error",
message: "Internal server error",
});
}
}
// Forgot password method
static async forgotPassword(req: Request, res: Response): Promise<void> {
try {
const { email } = req.body;
// Find user by email
const user = await User.findOne({ email });
if (!user) {
res.status(404).json({
status: 'error',
message: 'No account found with that email',
});
return;
}
// Verify email service before proceeding
const isEmailServiceWorking = await EmailService.verifyConnection();
if (!isEmailServiceWorking) {
res.status(500).json({
status: 'error',
message: 'Email service is not available. Please try again later.',
});
return;
}
// Generate reset token
const resetToken = crypto.randomBytes(32).toString('hex');
user.resetPasswordToken = resetToken;
user.resetPasswordExpires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await user.save();
try {
// Send password reset email
await EmailService.sendPasswordResetEmail(email, user.name, resetToken);
res.json({
status: 'success',
message: 'Password reset instructions sent to your email',
});
} catch (emailError) {
console.error('Failed to send password reset email:', emailError);
// Reset the token since email failed
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
res.status(500).json({
status: 'error',
message: 'Failed to send password reset email. Please try again later.',
});
}
} catch (error) {
console.error('Forgot password error:', error);
res.status(500).json({
status: 'error',
message: 'Internal server error',
});
}
}
// Reset password method
static async resetPassword(req: Request, res: Response): Promise<void> {
try {
const { token } = req.params;
const { password } = req.body;
// Find user by reset token
const user = await User.findOne({
resetPasswordToken: token,
resetPasswordExpires: { $gt: new Date() },
});
if (!user) {
res.status(400).json({
status: "error",
message: "Invalid or expired reset token",
});
return;
}
// Hash new password and save
const hashedPassword = await bcrypt.hash(
password,
config.bcrypt.saltRounds
);
user.password = hashedPassword;
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
res.json({
status: "success",
message: "Password reset successfully",
});
} catch (error) {
res.status(500).json({
status: "error",
message: "Internal server error",
});
}
}
}
And Finally user.routes.js
import { Router } from 'express';
import { UserController } from '../controllers/user.controller';
import { validateRequest } from '../middleware/validate';
import { signupSchema, loginSchema, resetPasswordSchema, forgotPasswordSchema } from '../validators/user.validators';
/**
* Initializes a new Router instance.
* This router will be used to define user-related routes.
*/
const router = Router();
router.post('/signup', validateRequest(signupSchema), UserController.signup);
router.post('/login', validateRequest(loginSchema), UserController.login);
router.get('/verify-email/:token', UserController.verifyEmail);
router.post('/forgot-password', validateRequest(forgotPasswordSchema), UserController.forgotPassword);
router.post('/reset-password/:token', validateRequest(resetPasswordSchema), UserController.resetPassword);
export default router;
And Thats's it 😊😊😊
Do
npm run build
to compile your code andnpm run dev
to test...If this article was helpful like ❤️❤️❤️ and comment feel free to drop your questions in the comment too😉
See You On The Next👌👌
Top comments (0)