Welcome to Chapter 5! In this crucial phase of our journey, we’ll dive deep into securing our application by implementing robust user authentication and authorization. This involves enabling users to register for an account, log in, and then access protected resources based on their authenticated status. We’ll leverage JSON Web Tokens (JWT) as our primary mechanism for stateless authentication, a cornerstone of modern API security.
Securing your application is paramount for protecting user data and maintaining system integrity. Without proper authentication and authorization, any user could potentially access sensitive information or perform unauthorized actions. This chapter will lay the groundwork for a secure backend, ensuring that only legitimate and authorized users can interact with specific parts of our API. We’ll build upon the foundational Fastify setup and database integration from previous chapters, enhancing our User model and introducing new services and plugins.
By the end of this chapter, you will have a fully functional authentication system that allows users to register, log in, receive a JWT, and use that token to access protected API endpoints. We will implement password hashing, secure token generation and verification, and middleware-like route protection. This incremental build ensures that each component is tested and understood before we move on to more complex features.
Planning & Design
Before we jump into coding, let’s outline the architecture, API endpoints, and file structure for our authentication system.
Component Architecture
The following diagram illustrates the high-level flow for user registration and login, highlighting the role of JWTs.
Database Schema Updates
We need to ensure our User model in Prisma (or your chosen ORM/ODM) is equipped to handle passwords securely. This means adding a password field and ensuring email uniqueness.
File: prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // Or your chosen database
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique // Ensure emails are unique
password String // Store hashed passwords
firstName String
lastName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
After updating your schema.prisma file, remember to run a migration:
npx prisma migrate dev --name add_password_to_user
API Endpoints Design
We’ll design the following RESTful API endpoints:
POST /auth/register: Creates a new user account.- Request Body:
{ email, password, firstName, lastName? } - Response:
{ token, user: { id, email, firstName } }
- Request Body:
POST /auth/login: Authenticates an existing user.- Request Body:
{ email, password } - Response:
{ token, user: { id, email, firstName } }
- Request Body:
GET /auth/me: Retrieves the profile of the authenticated user.- Request Header:
Authorization: Bearer <JWT> - Response:
{ id, email, firstName, lastName? }
- Request Header:
File Structure
We will organize our authentication logic within a dedicated auth module and integrate it as a Fastify plugin.
src/
├── config/
│ └── index.ts # Global configuration including JWT settings
├── db/
│ └── prisma.ts # Prisma client instance
│ └── schema.prisma # Database schema (updated)
├── plugins/
│ └── auth.ts # Fastify JWT plugin setup
├── routes/
│ └── auth.ts # Authentication specific routes
├── schemas/
│ └── auth.ts # Zod schemas for request validation
│ └── user.ts # Zod schema for user data
├── services/
│ └── user.ts # User-related database operations
├── utils/
│ └── jwt.ts # JWT token generation/verification utilities
│ └── password.ts # Password hashing utilities
├── app.ts # Main Fastify application setup
└── server.ts # Server entry point
Step-by-Step Implementation
a) Setup/Configuration
First, let’s install the necessary packages for password hashing and JWT handling.
npm install bcrypt jsonwebtoken @fastify/jwt zod
npm install -D @types/bcrypt @types/jsonwebtoken
bcrypt: For securely hashing and comparing passwords.jsonwebtoken: To create and verify JWTs.@fastify/jwt: A Fastify plugin that integratesjsonwebtokenseamlessly.zod: For robust schema validation.
Next, we’ll update our environment variables and configuration to include JWT secrets and expiration times.
File: .env (add these lines)
# JWT Configuration
JWT_SECRET="your_super_secret_jwt_key_here_change_me_in_production"
JWT_EXPIRES_IN="1d" # e.g., 1h, 2d, 10m
Explanation:
JWT_SECRET: A strong, random string used to sign your JWTs. Never hardcode this in production; use environment variables or a secrets manager.JWT_EXPIRES_IN: Defines how long the token will be valid. Shorter durations are generally more secure.
Now, let’s load these into our application configuration.
File: src/config/index.ts (update with JWT settings)
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000,
nodeEnv: process.env.NODE_ENV || 'development',
databaseUrl: process.env.DATABASE_URL || 'postgresql://user:password@localhost:5432/mydb',
logLevel: process.env.LOG_LEVEL || 'info',
jwt: {
secret: process.env.JWT_SECRET || 'supersecretdefaultkey_change_in_prod',
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
},
// Add other configurations as needed
};
Explanation: We’ve added a jwt object to our config, pulling the secret and expiresIn from environment variables. A fallback default is provided for development, but it’s crucial to use strong, unique values in production.
b) Core Implementation
Let’s build out the core components step by step.
1. Password Hashing Utilities
We’ll create a utility file for password hashing and comparison using bcrypt.
File: src/utils/password.ts
import bcrypt from 'bcrypt';
import { FastifyBaseLogger } from 'fastify';
const SALT_ROUNDS = 10; // Recommended salt rounds for bcrypt
/**
* Hashes a plain-text password using bcrypt.
* @param password The plain-text password to hash.
* @param logger Optional Fastify logger instance.
* @returns The hashed password.
*/
export async function hashPassword(password: string, logger?: FastifyBaseLogger): Promise<string> {
try {
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
logger?.debug('Password hashed successfully.');
return hashedPassword;
} catch (error) {
logger?.error({ error }, 'Failed to hash password.');
throw new Error('Failed to hash password.');
}
}
/**
* Compares a plain-text password with a hashed password.
* @param plainPassword The plain-text password.
* @param hashedPassword The hashed password from the database.
* @param logger Optional Fastify logger instance.
* @returns True if passwords match, false otherwise.
*/
export async function comparePassword(
plainPassword: string,
hashedPassword: string,
logger?: FastifyBaseLogger
): Promise<boolean> {
try {
const isMatch = await bcrypt.compare(plainPassword, hashedPassword);
logger?.debug('Password comparison completed.');
return isMatch;
} catch (error) {
logger?.error({ error }, 'Failed to compare passwords.');
throw new Error('Failed to compare passwords.');
}
}
Explanation:
SALT_ROUNDS: Determines the computational cost of hashing.10is a good balance for most applications. Higher values are more secure but slower.hashPassword: Takes a plain password and returns its bcrypt hash.comparePassword: Takes a plain password and a hash, returningtrueif they match.- Production-Ready: Includes
try-catchblocks for error handling and integrates with a logger for observability.
2. User Service
Our userService will handle database interactions for user creation and retrieval.
File: src/services/user.ts
import { PrismaClient, User } from '@prisma/client';
import { FastifyBaseLogger } from 'fastify';
import { hashPassword } from '../utils/password';
// Define a type for user creation data (excluding password initially)
export type UserCreateInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
export type UserLoginInput = Pick<User, 'email' | 'password'>;
export class UserService {
constructor(private prisma: PrismaClient, private logger: FastifyBaseLogger) {}
/**
* Creates a new user in the database.
* @param data User creation data.
* @returns The created user object (without password).
*/
async createUser(data: UserCreateInput): Promise<Omit<User, 'password'>> {
this.logger.info({ email: data.email }, 'Attempting to create a new user.');
try {
// Hash the password before storing it
const hashedPassword = await hashPassword(data.password, this.logger);
const user = await this.prisma.user.create({
data: {
...data,
password: hashedPassword,
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
createdAt: true,
updatedAt: true,
},
});
this.logger.info({ userId: user.id }, 'User created successfully.');
return user;
} catch (error: any) {
this.logger.error({ error, email: data.email }, 'Failed to create user.');
if (error.code === 'P2002' && error.meta?.target?.includes('email')) {
throw new Error('User with this email already exists.');
}
throw new Error('Could not create user.');
}
}
/**
* Finds a user by their email address.
* @param email The user's email.
* @returns The user object including password, or null if not found.
*/
async findUserByEmail(email: string): Promise<User | null> {
this.logger.debug({ email }, 'Attempting to find user by email.');
try {
const user = await this.prisma.user.findUnique({
where: { email },
});
if (user) {
this.logger.debug({ userId: user.id }, 'User found by email.');
} else {
this.logger.debug({ email }, 'User not found by email.');
}
return user;
} catch (error) {
this.logger.error({ error, email }, 'Failed to find user by email.');
throw new Error('Could not retrieve user data.');
}
}
/**
* Finds a user by their ID.
* @param id The user's ID.
* @returns The user object (without password), or null if not found.
*/
async findUserById(id: string): Promise<Omit<User, 'password'> | null> {
this.logger.debug({ id }, 'Attempting to find user by ID.');
try {
const user = await this.prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
createdAt: true,
updatedAt: true,
},
});
if (user) {
this.logger.debug({ userId: user.id }, 'User found by ID.');
} else {
this.logger.debug({ id }, 'User not found by ID.');
}
return user;
} catch (error) {
this.logger.error({ error, id }, 'Failed to find user by ID.');
throw new Error('Could not retrieve user data.');
}
}
}
Explanation:
UserCreateInputandUserLoginInput: Type definitions for clarity and type safety.createUser: Hashes the password before saving to the database. It also handles the unique email constraint error (P2002). It explicitly selects fields to return, excluding the password.findUserByEmail: Retrieves a user by email, including the hashed password for comparison during login.findUserById: Retrieves a user by ID, explicitly excluding the password for general use cases.- Production-Ready: Comprehensive error handling, specific Prisma error code handling, and detailed logging for all operations.
3. Zod Schemas for Validation
We’ll define Zod schemas for validating our request bodies for registration and login.
File: src/schemas/auth.ts
import { z } from 'zod';
// Schema for user registration
export const registerUserSchema = z.object({
email: z.string().email('Invalid email address.'),
password: z.string().min(8, 'Password must be at least 8 characters long.'),
firstName: z.string().min(2, 'First name is required.'),
lastName: z.string().optional(),
});
// Schema for user login
export const loginUserSchema = z.object({
email: z.string().email('Invalid email address.'),
password: z.string().min(1, 'Password is required.'), // We don't need min length here, just presence
});
// Schema for JWT payload (what we store in the token)
export const jwtPayloadSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
export type RegisterUserInput = z.infer<typeof registerUserSchema>;
export type LoginUserInput = z.infer<typeof loginUserSchema>;
export type JwtPayload = z.infer<typeof jwtPayloadSchema>;
Explanation:
registerUserSchema: Validates email format, password minimum length, and presence of first name.loginUserSchema: Validates email format and presence of password.jwtPayloadSchema: Defines the expected structure of our JWT payload, ensuring type safety when decoding tokens.- Production-Ready: Provides clear validation messages and uses
zodfor type inference, making our code more robust.
4. Fastify JWT Plugin
We’ll set up @fastify/jwt as a Fastify plugin, making JWT capabilities available across our application.
File: src/plugins/auth.ts
import { FastifyPluginAsync, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import fastifyJwt from '@fastify/jwt';
import { config } from '../config';
import { JwtPayload, jwtPayloadSchema } from '../schemas/auth';
import { UserService } from '../services/user';
// Extend FastifyRequest to include a 'user' property after JWT verification
declare module 'fastify' {
interface FastifyRequest {
user?: JwtPayload; // The decoded JWT payload
}
}
const authPlugin: FastifyPluginAsync = async (fastify) => {
fastify.register(fastifyJwt, {
secret: config.jwt.secret,
sign: {
expiresIn: config.jwt.expiresIn,
},
cookie: { // Optional: if you want to use cookies instead of Authorization header
cookieName: 'refreshToken',
signed: false, // Set to true if using signed cookies
},
messages: {
badRequest: 'Authorization header missing or invalid.',
noAuthorizationInHeader: 'Authorization header is missing.',
authorizationTokenExpired: 'Authorization token expired.',
authorizationTokenInvalid: 'Authorization token is invalid.',
// Custom message for missing token (e.g., if you disable auto-verify)
// Generic authorization error message for any other JWT issue
authorizationTokenUntrusted: 'Authorization token cannot be trusted.'
}
});
// Decorate Fastify instance to easily access JWT signing
fastify.decorate('jwtSign', async (payload: JwtPayload) => {
try {
const token = await fastify.jwt.sign(payload);
fastify.log.debug({ userId: payload.id }, 'JWT token signed.');
return token;
} catch (error) {
fastify.log.error({ error, payload }, 'Failed to sign JWT token.');
throw new Error('Failed to generate authentication token.');
}
});
// Decorate Fastify instance to easily access JWT verification
fastify.decorate('authenticate', async (request: FastifyRequest) => {
try {
const decoded = await request.jwtVerify<JwtPayload>();
// Validate the decoded payload against our schema
const parsedPayload = jwtPayloadSchema.parse(decoded);
request.user = parsedPayload; // Attach the decoded user payload to the request
fastify.log.debug({ userId: request.user.id }, 'JWT token verified and user attached to request.');
} catch (error: any) {
fastify.log.warn({ error: error.message }, 'Authentication failed: Invalid or expired token.');
// Re-throw the error to be caught by Fastify's error handler
// This will result in a 401 Unauthorized response
throw fastify.httpErrors.unauthorized('Invalid or expired authentication token.');
}
});
};
export default fp(authPlugin, {
name: 'auth-plugin',
dependencies: ['app-config'], // Assuming a config plugin is registered
});
// Extend FastifyInstance with our custom decorators
declare module 'fastify' {
interface FastifyInstance {
jwtSign(payload: JwtPayload): Promise<string>;
authenticate(request: FastifyRequest): Promise<void>;
userService: UserService; // Add userService to Fastify instance
}
}
Explanation:
fastifyJwt: Registers the JWT plugin with our secret and expiration.declare module 'fastify': Extends Fastify’s types to includerequest.userafter successful authentication, which is crucial for TypeScript.fastify.decorate('jwtSign'): A utility function to easily sign JWTs with our configuration.fastify.decorate('authenticate'): An authentication hook (can be used as a pre-handler) that verifies the JWT from theAuthorizationheader, decodes it, validates its structure with Zod, and attaches the decoded payload torequest.user.- Production-Ready: Custom error messages for common JWT issues, robust
try-catchblocks, integration with Fastify’s logger, and explicit type extensions. Theauthenticatedecorator is designed to throw a401 Unauthorizederror on failure, which Fastify handles gracefully.fp(fastify-plugin) is used to ensure the plugin’s decorators are available everywhere.
5. Authentication Routes
Now we’ll define our authentication routes for user registration, login, and profile retrieval.
File: src/routes/auth.ts
import { FastifyPluginAsync } from 'fastify';
import { registerUserSchema, loginUserSchema } from '../schemas/auth';
import { comparePassword } from '../utils/password';
const authRoutes: FastifyPluginAsync = async (fastify) => {
const { userService, authenticate, jwtSign } = fastify; // Destructure services and decorators
// Route for user registration
fastify.post('/register', {
schema: {
body: registerUserSchema,
response: {
201: {
type: 'object',
properties: {
token: { type: 'string' },
user: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
firstName: { type: 'string' },
lastName: { type: 'string' },
},
},
},
},
},
},
config: {
// Custom config property to indicate this route does not require authentication
// Useful for global authentication middleware later if needed
isPublic: true,
},
}, async (request, reply) => {
const { email, password, firstName, lastName } = request.body as typeof registerUserSchema._type;
request.log.info({ email }, 'Register attempt for new user.');
// Check if user already exists
const existingUser = await userService.findUserByEmail(email);
if (existingUser) {
request.log.warn({ email }, 'Registration failed: User with this email already exists.');
throw fastify.httpErrors.conflict('User with this email already exists.');
}
const newUser = await userService.createUser({ email, password, firstName, lastName });
const token = await jwtSign({ id: newUser.id, email: newUser.email });
reply.status(201).send({
token,
user: {
id: newUser.id,
email: newUser.email,
firstName: newUser.firstName,
lastName: newUser.lastName,
},
});
});
// Route for user login
fastify.post('/login', {
schema: {
body: loginUserSchema,
response: {
200: {
type: 'object',
properties: {
token: { type: 'string' },
user: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
firstName: { type: 'string' },
lastName: { type: 'string' },
},
},
},
},
},
},
config: {
isPublic: true,
},
}, async (request, reply) => {
const { email, password } = request.body as typeof loginUserSchema._type;
request.log.info({ email }, 'Login attempt for user.');
const user = await userService.findUserByEmail(email);
if (!user) {
request.log.warn({ email }, 'Login failed: User not found.');
throw fastify.httpErrors.unauthorized('Invalid credentials.');
}
const isPasswordValid = await comparePassword(password, user.password, request.log);
if (!isPasswordValid) {
request.log.warn({ email }, 'Login failed: Invalid password.');
throw fastify.httpErrors.unauthorized('Invalid credentials.');
}
const token = await jwtSign({ id: user.id, email: user.email });
reply.send({
token,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
},
});
});
// Protected route to get current user's profile
fastify.get('/me', {
preHandler: [authenticate], // This route requires authentication
schema: {
response: {
200: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
firstName: { type: 'string' },
lastName: { type: 'string' },
},
},
},
},
}, async (request, reply) => {
// request.user is available here due to the authenticate preHandler
if (!request.user?.id) {
request.log.error('Authenticated user ID not found in request context.');
throw fastify.httpErrors.internalServerError('User context missing after authentication.');
}
const userProfile = await userService.findUserById(request.user.id);
if (!userProfile) {
request.log.error({ userId: request.user.id }, 'Authenticated user not found in DB.');
throw fastify.httpErrors.notFound('User profile not found.');
}
reply.send(userProfile);
});
};
export default authRoutes;
Explanation:
- Registration (
/register):- Uses
registerUserSchemafor request body validation. - Checks for existing users by email to prevent duplicates.
- Calls
userService.createUserto hash the password and save the user. - Generates a JWT using
jwtSignand returns it along with basic user info. - Production-Ready: Handles
409 Conflictfor existing users, usesfastify.httpErrorsfor standardized error responses, and logs key events.
- Uses
- Login (
/login):- Uses
loginUserSchemafor request body validation. - Retrieves the user by email.
- Compares the provided password with the stored hash using
comparePassword. - If credentials are valid, generates and returns a JWT.
- Production-Ready: Returns
401 Unauthorizedfor invalid credentials without revealing whether the email or password was incorrect (security best practice).
- Uses
- Profile (
/me):- Uses
preHandler: [authenticate]to ensure only authenticated requests reach the handler. Ifauthenticatefails, it automatically sends a401. - Accesses
request.user(populated byauthenticate) to get the current user’s ID. - Retrieves the user’s profile from the database (without the password).
- Production-Ready: Demonstrates route protection, relies on the
authenticatedecorator for centralizing auth logic, and handles cases where the user might not be found despite a valid token (e.g., deleted user).
- Uses
6. Integrate into Main Application
Finally, we need to register our new authPlugin and authRoutes in our main Fastify application.
File: src/app.ts (update)
import Fastify from 'fastify';
import { config } from './config';
import { PrismaClient } from '@prisma/client';
import { UserService } from './services/user';
// Plugins
import fastifySensible from '@fastify/sensible'; // For httpErrors and .notFound()
import fastifyHelmet from '@fastify/helmet'; // For security headers
import fastifyCors from '@fastify/cors'; // For CORS
import fastifyRateLimit from '@fastify/rate-limit'; // For rate limiting (future chapter)
import fastifySwagger from '@fastify/swagger'; // For API documentation (future chapter)
import fastifySwaggerUi from '@fastify/swagger-ui'; // For API documentation UI (future chapter)
// Custom Plugins
import authPlugin from './plugins/auth'; // Our new auth plugin
import dbPlugin from './plugins/db'; // Assuming you have a DB plugin
import appConfigPlugin from './plugins/config'; // Assuming you have a config plugin
// Routes
import authRoutes from './routes/auth'; // Our new auth routes
// import exampleRoutes from './routes/example'; // Example routes from previous chapters
// Create a logger instance
const logger = Fastify({
logger: {
level: config.logLevel,
transport: {
target: 'pino-pretty', // Pretty print logs in development
options: {
colorize: true,
translateTime: 'SYS:HH:MM:ss',
ignore: 'pid,hostname',
},
},
},
});
export async function buildApp() {
const fastify = Fastify({
logger: logger.logger, // Use the configured logger
disableRequestLogging: true, // Disable default request logging to use custom hooks
});
// 1. Register Configuration Plugin
await fastify.register(appConfigPlugin); // Makes 'config' available
fastify.log.info('Configuration plugin registered.');
// 2. Register DB Plugin (Prisma)
await fastify.register(dbPlugin); // Makes 'prisma' available
fastify.log.info('Database plugin registered.');
// Extend Fastify instance with UserService
fastify.decorate('userService', new UserService(fastify.prisma, fastify.log));
fastify.log.info('UserService decorated onto Fastify instance.');
// 3. Register Core Fastify Plugins
await fastify.register(fastifySensible); // Provides httpErrors and .notFound()
await fastify.register(fastifyHelmet, { contentSecurityPolicy: false }); // Basic security headers
await fastify.register(fastifyCors, { origin: '*' }); // Allow all origins for now, restrict in production
// await fastify.register(fastifyRateLimit, { max: 100, timeWindow: '1 minute' }); // Rate limiting (future)
fastify.log.info('Core Fastify plugins registered: sensible, helmet, cors.');
// 4. Register Our Custom Auth Plugin
await fastify.register(authPlugin); // Registers JWT capabilities and authentication decorator
fastify.log.info('Authentication plugin registered.');
// 5. Register Routes
fastify.register(authRoutes, { prefix: '/auth' }); // Our authentication routes
// fastify.register(exampleRoutes, { prefix: '/api/v1' }); // Example routes from previous chapter
fastify.log.info('Authentication routes registered with prefix /auth.');
// Global Error Handler (from previous chapter)
fastify.setErrorHandler((error, request, reply) => {
if (error.validation) {
request.log.warn({ validationErrors: error.validation, url: request.url }, 'Validation error occurred.');
reply.status(400).send({
statusCode: 400,
code: 'FST_VALIDATION_FAILED',
error: 'Bad Request',
message: error.message,
details: error.validation,
});
return;
}
if (error.statusCode) {
// Handle known HTTP errors (e.g., from fastify.httpErrors)
request.log.error({ error: error.message, statusCode: error.statusCode }, 'Handled HTTP error.');
reply.status(error.statusCode).send({
statusCode: error.statusCode,
code: error.code || 'HTTP_ERROR',
error: error.name || 'Error',
message: error.message,
});
return;
}
// Log unexpected errors
request.log.error({ error: error.message, stack: error.stack, url: request.url }, 'An unexpected error occurred.');
reply.status(500).send({
statusCode: 500,
code: 'INTERNAL_SERVER_ERROR',
error: 'Internal Server Error',
message: 'An unexpected error occurred.',
});
});
// Add a hook to log all requests
fastify.addHook('onRequest', async (request) => {
request.log.info({ method: request.method, url: request.url, ip: request.ip }, 'Incoming request');
});
// Add a hook to log all responses
fastify.addHook('onResponse', async (request, reply) => {
request.log.info({
method: request.method,
url: request.url,
statusCode: reply.statusCode,
responseTime: reply.getResponseTime(),
}, 'Request completed');
});
return fastify;
}
Explanation:
- We’ve added
authPluginandauthRoutesregistration. - The
UserServiceis instantiated and decorated onto thefastifyinstance, making it easily accessible in routes and other plugins without needing to passprismaandloggeraround explicitly. - The global error handler is robust, distinguishing between validation errors, known HTTP errors, and unexpected server errors, providing appropriate logging and responses.
c) Testing This Component
Let’s test our authentication system using curl or a tool like Postman/Insomnia. Ensure your server is running (npm run dev or npm start).
Register a New User:
curl -X POST \ http://localhost:3000/auth/register \ -H 'Content-Type: application/json' \ -d '{ "email": "test@example.com", "password": "securepassword123", "firstName": "John", "lastName": "Doe" }'- Expected Response (201 Created): You should receive a JWT and user details.
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": "uuid-of-user", "email": "test@example.com", "firstName": "John", "lastName": "Doe" } } - Troubleshooting: If you get a
400 Bad Request, check your request body againstregisterUserSchema. If409 Conflict, the email is already registered.
- Expected Response (201 Created): You should receive a JWT and user details.
Attempt to Register with Existing Email:
curl -X POST \ http://localhost:3000/auth/register \ -H 'Content-Type: application/json' \ -d '{ "email": "test@example.com", "password": "anotherpassword", "firstName": "Jane" }'- Expected Response (409 Conflict):
{ "statusCode": 409, "code": "FST_CONFLICT", "error": "Conflict", "message": "User with this email already exists." }
- Expected Response (409 Conflict):
Log In an Existing User:
curl -X POST \ http://localhost:3000/auth/login \ -H 'Content-Type: application/json' \ -d '{ "email": "test@example.com", "password": "securepassword123" }'- Expected Response (200 OK): You should receive a new JWT and user details.
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": "uuid-of-user", "email": "test@example.com", "firstName": "John", "lastName": "Doe" } } - Troubleshooting: If
401 Unauthorized, check your email/password.
- Expected Response (200 OK): You should receive a new JWT and user details.
Access Protected Route (
/me) without Token:curl -X GET http://localhost:3000/auth/me- Expected Response (401 Unauthorized):
{ "statusCode": 401, "code": "FST_UNAUTHORIZED", "error": "Unauthorized", "message": "Authorization header is missing." }
- Expected Response (401 Unauthorized):
Access Protected Route (
/me) with Valid Token:- Take the
tokenfrom a successful login response.
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." # Replace with your actual token curl -X GET \ http://localhost:3000/auth/me \ -H "Authorization: Bearer $TOKEN"- Expected Response (200 OK): You should receive the user’s profile.
{ "id": "uuid-of-user", "email": "test@example.com", "firstName": "John", "lastName": "Doe", "createdAt": "...", "updatedAt": "..." } - Troubleshooting: If
401 Unauthorized, double-check the token, ensure it’s not expired, and theBearerprefix is correct. Check server logs for more details.
- Take the
Production Considerations
Error Handling
- Specific Error Codes: We’ve used
fastify.httpErrorsto return standard HTTP status codes (400, 401, 409) with informative messages. - Detail Level: Avoid leaking sensitive information (e.g., specific database errors) in production error messages. Our error handler ensures this by providing generic messages for internal server errors.
- Validation Errors: Zod (via Fastify’s validation) provides detailed validation errors, which are helpful during development but might be simplified for production to prevent enumeration attacks.
Performance Optimization
- Password Hashing:
bcryptis intentionally CPU-intensive. For high-throughput systems, consider offloading hashing to a separate worker thread or service if it becomes a bottleneck, though for most applications, it’s acceptable. - JWT Verification: JWT verification is fast as it’s stateless. No database lookups are needed for each protected route, contributing to performance.
Security Considerations
- JWT Secret: The
JWT_SECRETmust be a strong, random, and long string. Never commit it to version control. Use environment variables or a secrets management service (like AWS Secrets Manager) in production. - Token Expiration: Set a reasonable
JWT_EXPIRES_IN. Shorter expirations improve security by limiting the window for compromised tokens. Consider implementing refresh tokens for a better user experience with short-lived access tokens (an advanced topic for a later chapter). - HTTPS: Always deploy your API over HTTPS. This prevents tokens from being intercepted in transit. We’ll cover this during deployment.
- Password Storage:
bcryptis crucial for securely storing passwords. Never store plain-text passwords. - Brute-Force Protection: Implement rate limiting on login and registration endpoints to prevent brute-force attacks. We briefly added a placeholder for
fastify-rate-limitinapp.ts, which we’ll configure in a future chapter. - CORS: Properly configure
fastify-corsto only allow requests from your trusted client origins in production. Currently, it’s set to*for development ease. - XSS/CSRF: While JWTs are less susceptible to CSRF than session cookies, ensure your frontend follows best practices for storing and sending tokens (e.g.,
localStoragevshttpOnlycookies). Helmet provides basic XSS protection.
Logging and Monitoring
- Authentication Events: Log all successful and failed registration/login attempts. This is vital for security auditing and detecting suspicious activity. Our current implementation includes
request.logcalls for these events. - Token Issues: Log when tokens are invalid, expired, or missing.
- Structured Logging: Using
pino(Fastify’s default logger) ensures structured logs, making it easier to parse and analyze logs with tools like Elastic Stack or CloudWatch.
Code Review Checkpoint
At this point, we have significantly enhanced our application’s security posture.
New Files Created:
src/utils/password.ts: ContainshashPasswordandcomparePasswordfunctions.src/schemas/auth.ts: Defines Zod schemas forregisterUserSchema,loginUserSchema, andjwtPayloadSchema.src/plugins/auth.ts: Registers@fastify/jwtand providesjwtSignandauthenticatedecorators.src/services/user.ts: Encapsulates user-related database operations, including password hashing.src/routes/auth.ts: Defines API endpoints for user registration, login, and profile retrieval.
Files Modified:
.env: AddedJWT_SECRETandJWT_EXPIRES_IN.src/config/index.ts: Updated to load JWT configuration.prisma/schema.prisma: Addedpasswordfield to theUsermodel and ensuredemailis@unique.src/app.ts: RegisteredauthPlugin,authRoutes, and decorateduserService.
Integration:
The authPlugin integrates seamlessly with Fastify, decorating the instance with JWT signing and authentication capabilities. The authRoutes then utilize these decorators and the userService to implement the authentication flow. Our global error handler correctly processes authentication-related errors.
Common Issues & Solutions
401 Unauthorized - Authorization header is missing.- Issue: You’re trying to access a protected route without providing a JWT in the
Authorizationheader, or the header is malformed. - Solution: Ensure your request includes
Authorization: Bearer <your_jwt_token>and that<your_jwt_token>is correctly copied from a login response. - Prevention: Always test public routes without a token first, then protected routes with a valid token.
- Issue: You’re trying to access a protected route without providing a JWT in the
401 Unauthorized - Authorization token expired.orAuthorization token invalid.- Issue: The JWT you’re using has expired (based on
JWT_EXPIRES_IN) or is malformed/tampered with. - Solution: Obtain a new token by logging in again. If you suspect tampering, double-check your
JWT_SECRETin.envmatches the one used by the server. - Prevention: When developing, set a longer
JWT_EXPIRES_IN(e.g.,1hor24h) to avoid frequent relogging. In production, keep it shorter for security.
- Issue: The JWT you’re using has expired (based on
500 Internal Server Errorduring registration/login.- Issue: This is a generic error and could indicate various problems, often related to database connectivity, password hashing failures, or an unhandled exception in
userService. - Solution: Check your server logs (
npm run devoutput). Look for specific error messages from Prisma, bcrypt, or your custom error handling. Ensure yourDATABASE_URLin.envis correct and the database is running. Verifynpx prisma migrate devran successfully. - Prevention: Robust
try-catchblocks and detailed logging are your best friends here.
- Issue: This is a generic error and could indicate various problems, often related to database connectivity, password hashing failures, or an unhandled exception in
400 Bad Request - Validation error- Issue: Your request body doesn’t conform to the Zod schema defined for the endpoint (e.g., password too short, invalid email format).
- Solution: Review the error details provided in the response (our custom error handler includes
error.validation.details) and adjust your request body to match the schema. - Prevention: Refer to your
src/schemas/auth.tsfile for exact requirements.
Testing & Verification
To thoroughly test and verify the work from this chapter:
- Start your Fastify server:
npm run dev - Database state: Ensure your database is up and running and you’ve run the Prisma migration (
npx prisma migrate dev --name add_password_to_user). - Registration:
- Register a new user with valid data. Verify a
201 Createdresponse with a JWT. - Attempt to register the same user email. Verify a
409 Conflictresponse. - Attempt to register with invalid data (e.g., short password, invalid email). Verify
400 Bad Requestresponses.
- Register a new user with valid data. Verify a
- Login:
- Log in with the registered user’s correct credentials. Verify a
200 OKresponse with a new JWT. - Attempt to log in with incorrect password for an existing user. Verify
401 Unauthorized. - Attempt to log in with a non-existent email. Verify
401 Unauthorized.
- Log in with the registered user’s correct credentials. Verify a
- Protected Route (
/auth/me):- Attempt to access
/auth/mewithout anyAuthorizationheader. Verify401 Unauthorized. - Attempt to access
/auth/mewith a malformed or invalid JWT. Verify401 Unauthorized. - Use a valid JWT (obtained from a login) to access
/auth/me. Verify a200 OKresponse with the user’s profile data. - Use an expired JWT to access
/auth/me. Verify401 Unauthorized.
- Attempt to access
By following these steps, you can confidently verify that your authentication and authorization system is working as expected and handling various scenarios gracefully.
Summary & Next Steps
Congratulations! You’ve successfully implemented a robust user authentication and authorization system using JSON Web Tokens (JWT) with Fastify, bcrypt, and Zod. We covered:
- Setting up JWT configuration and integrating
@fastify/jwt. - Securely hashing and comparing passwords with
bcrypt. - Creating a dedicated
UserServicefor database interactions. - Defining clear validation schemas with
Zod. - Building API endpoints for user registration, login, and profile retrieval.
- Protecting routes using Fastify pre-handlers and our custom
authenticatedecorator. - Implementing comprehensive error handling, logging, and considering production security best practices.
This chapter has provided a solid foundation for user management. In the next chapter, we will build upon this by implementing Role-Based Access Control (RBAC). This will allow us to define different user roles (e.g., admin, user) and restrict access to specific routes or resources based on those roles, further enhancing our application’s security and flexibility.