Phase 0: Backend Setup (With Detailed Comments)

← Back to Main Tutorial

Understanding the Problem

In this phase, we need to set up the backend of our authentication application. This includes creating the project structure, installing dependencies, setting up Sequelize, initializing Express, and configuring security middlewares.

The goal is to have a well-structured Express application ready for implementing authentication features in later phases.

What we're building: A secure backend architecture that will support user authentication with protection against common security vulnerabilities like CSRF attacks.

Planning the Solution

  1. Create the project folder structure
  2. Set up the README.md file
  3. Create a .gitignore file
  4. Initialize Git repository
  5. Create backend and frontend folders
  6. Install necessary backend dependencies
  7. Set up environment variables
  8. Configure Sequelize
  9. Set up Express application with security middleware
  10. Create routes
  11. Configure the server
  12. Set up User model, migration, and seeder
  13. Test the server

Implementing the Solution

1. Create Project Folder Structure

Start by creating a root folder for your project. The name should be meaningful and represent your project.

mkdir my_authentication_project
cd my_authentication_project

Why this matters: Good project structure is the foundation of maintainable code. Separating concerns through folder structure makes the application easier to navigate and modify.

2. Set up README.md

Create a README.md file at the root of your project to document your API and database schema.

touch README.md

Add the following content to README.md:

# My Authentication Project

## Database Schema Design

![db-schema](./images/db-schema.png)

## API Documentation

### All endpoints that require authentication

All endpoints that require a current user to be logged in.

* Request: endpoints that require authentication
* Error Response: Require authentication
  * Status Code: 401
  * Headers:
    * Content-Type: application/json
  * Body:

    ```json
    {
      "message": "Authentication required"
    }
    ```

Documentation purpose: The README provides essential information about your API, making it easier for other developers (or your future self) to understand how to use it. Good documentation is a mark of professional software development.

3. Create .gitignore File

Create a .gitignore file at the root to exclude certain files from version control:

touch .gitignore

Add the following content to .gitignore:

node_modules
.env
build
.DS_Store
*.db

Why we exclude these files:

  • node_modules: Contains all dependencies, often very large and should be installed by npm instead of tracked in git
  • .env: Contains sensitive information like API keys and passwords that shouldn't be public
  • build: Contains compiled code that's generated from source files
  • .DS_Store: macOS system files that aren't part of the project
  • *.db: Database files that could be large and contain sensitive data

4. Initialize Git Repository

Configure Git to use 'main' as the default branch, initialize the repository, and make your first commit:

git config --global init.defaultBranch main
git init
git add .
git commit -m 'Initial commit'

Create a remote GitHub repository and connect it to your local repository:

git remote add origin <github-remote-url>
git push origin main

Version control benefits: Git allows you to track changes, revert to previous versions if needed, and collaborate with others. Using main as the default branch name follows current best practices.

5. Create Backend and Frontend Folders

Create folders to separate backend and frontend code:

mkdir backend frontend images

Separation of concerns: Keeping frontend and backend code separate helps maintain a clear architecture where each part has distinct responsibilities. The images folder will store assets like database schema diagrams.

6. Install Backend Dependencies

Initialize the package.json file in the backend folder and install necessary dependencies:

cd backend
npm init -y
npm install cookie-parser cors csurf dotenv express express-async-errors helmet jsonwebtoken morgan per-env sequelize@6 sequelize-cli@6 pg
npm install -D sqlite3 dotenv-cli nodemon

Dependencies explained:

  • cookie-parser: Parses cookies from HTTP requests
  • cors: Enables Cross-Origin Resource Sharing for API access
  • csurf: Provides protection against Cross-Site Request Forgery attacks
  • dotenv: Loads environment variables from .env files
  • express: Web framework for Node.js
  • express-async-errors: Automatically catches errors in async/await functions
  • helmet: Sets HTTP headers for security
  • jsonwebtoken: Implements JWT for secure authentication
  • morgan: HTTP request logger middleware
  • per-env: Runs different scripts based on the environment
  • sequelize: ORM for database operations
  • pg: PostgreSQL driver for production use
  • sqlite3: Lightweight database for development (dev dependency)
  • nodemon: Auto-restarts server during development (dev dependency)

7. Set Up Environment Variables

Create a .env file in the backend folder:

touch .env

Add the following content to the .env file:

PORT=8000
DB_FILE=db/dev.db
JWT_SECRET=your_secret_here
JWT_EXPIRES_IN=604800
SCHEMA=your_schema_name_here

To generate a strong JWT secret, you can use the openssl command:

openssl rand -base64 10

Environment variable purposes:

  • PORT: The network port the server will listen on
  • DB_FILE: Location of the SQLite database file for development
  • JWT_SECRET: Secret key used to sign JWTs (should be long and random)
  • JWT_EXPIRES_IN: Token expiration time in seconds (604800 = 1 week)
  • SCHEMA: Database schema name (used for PostgreSQL in production)

Using environment variables keeps sensitive information out of your code and allows for different configurations in different environments.

8. Create Configuration Files

Create a config folder in the backend folder and add an index.js file for environment variables:

mkdir -p backend/config
touch backend/config/index.js

Add the following content to config/index.js:

// backend/config/index.js
module.exports = {
  environment: process.env.NODE_ENV || 'development',  // Default to development if not specified
  port: process.env.PORT || 8000,                      // Server port, fallback to 8000
  dbFile: process.env.DB_FILE,                         // Database file location
  jwtConfig: {
    secret: process.env.JWT_SECRET,                    // Secret key for JWT signing
    expiresIn: process.env.JWT_EXPIRES_IN              // JWT expiration time
  }
};

Configuration centralization: This file acts as a centralized place to access environment variables throughout the application. Using defaults (like || 'development') provides fallbacks if variables aren't set.

9. Set Up Sequelize

Create a .sequelizerc file in the backend folder:

touch backend/.sequelizerc

Add the following content to .sequelizerc:

// backend/.sequelizerc
const path = require('path');

module.exports = {
  config: path.resolve('config', 'database.js'),          // Database configuration path
  'models-path': path.resolve('db', 'models'),            // Models directory path
  'seeders-path': path.resolve('db', 'seeders'),          // Seed data directory path
  'migrations-path': path.resolve('db', 'migrations')     // Migrations directory path
};

Initialize Sequelize:

npx sequelize init

Update the config/database.js file with the following content:

// backend/config/database.js
const config = require('./index');

module.exports = {
  development: {
    storage: config.dbFile,                 // SQLite file location
    dialect: "sqlite",                      // Use SQLite in development
    seederStorage: "sequelize",             // Store seeder execution history in the database
    logQueryParameters: true,               // Log query parameters for debugging
    typeValidation: true                    // Enable strict type validation
  },
  production: {
    use_env_variable: 'DATABASE_URL',       // Use DATABASE_URL from environment
    dialect: 'postgres',                    // Use PostgreSQL in production
    seederStorage: 'sequelize',             // Store seeder execution history
    dialectOptions: {
      ssl: {
        require: true,                      // Require SSL for security
        rejectUnauthorized: false           // Allow self-signed certificates
      }
    },
    define: {
      schema: process.env.SCHEMA           // Set schema for PostgreSQL
    }
  }
};

Create a psql-setup-script.js file in the backend folder:

touch backend/psql-setup-script.js

Add the following content to psql-setup-script.js:

// backend/psql-setup-script.js
const { sequelize } = require('./db/models');

sequelize.showAllSchemas({ logging: false }).then(async (data) => {
  if (!data.includes(process.env.SCHEMA)) {
    await sequelize.createSchema(process.env.SCHEMA);  // Create schema if it doesn't exist
  }
});

Database configuration strategy: This setup provides different database configurations for development and production:

  • Development: Uses SQLite (file-based database) for simplicity and ease of setup
  • Production: Uses PostgreSQL with SSL for security and better performance at scale

The psql-setup-script.js is used during deployment to ensure the PostgreSQL schema exists before migrations run.

10. Set Up Express Application

Create an app.js file in the backend folder:

touch backend/app.js

Add the following content to app.js:

// backend/app.js
const express = require('express');
require('express-async-errors');                // Automatically catch async errors
const morgan = require('morgan');               // HTTP request logger
const cors = require('cors');                   // Cross-Origin Resource Sharing
const csurf = require('csurf');                 // CSRF protection
const helmet = require('helmet');               // Security headers
const cookieParser = require('cookie-parser');  // Parse cookies in requests

const { environment } = require('./config');
const isProduction = environment === 'production';

const app = express();

// Connect morgan middleware for logging HTTP requests
app.use(morgan('dev'));

// Parse cookies and JSON bodies
app.use(cookieParser());
app.use(express.json());

// Security middleware
if (!isProduction) {
  // Enable CORS only in development
  // In production, frontend and backend should be on same domain
  app.use(cors());
}

// Helmet helps set security headers
app.use(
  helmet.crossOriginResourcePolicy({
    policy: "cross-origin"                  // Allow resources to be shared cross-origin
  })
);

// Set the _csrf token and create req.csrfToken method
app.use(
  csurf({
    cookie: {
      secure: isProduction,                 // HTTPS only in production
      sameSite: isProduction && "Lax",      // Controls when cookies are sent with cross-site requests
      httpOnly: true                        // Prevents JavaScript access to cookies
    }
  })
);

// Connect routes
const routes = require('./routes');
app.use(routes);

module.exports = app;

Express middleware stack: This configuration sets up a secure Express application with several layers of middleware:

  1. morgan: Logs incoming HTTP requests for debugging
  2. cookieParser: Parses Cookie header and populates req.cookies
  3. express.json: Parses JSON request bodies
  4. cors: Allows cross-origin requests in development
  5. helmet: Sets HTTP headers to protect against common web vulnerabilities
  6. csurf: Protects against CSRF attacks by requiring a token for non-GET requests

The middleware order is important - each processes the request before passing it to the next.

11. Create Routes

Set up the routes folder and create an index.js file:

mkdir -p backend/routes
touch backend/routes/index.js

Add the following content to routes/index.js:

// backend/routes/index.js
const express = require('express');
const router = express.Router();

// Test route - serves as a health check and sets CSRF token
router.get('/hello/world', function(req, res) {
  res.cookie('XSRF-TOKEN', req.csrfToken());  // Set CSRF token in cookie for frontend
  res.send('Hello World!');                    // Simple response to confirm server works
});

// CSRF token restoration route
router.get("/api/csrf/restore", (req, res) => {
  const csrfToken = req.csrfToken();
  res.cookie("XSRF-TOKEN", csrfToken);         // Set token in cookie
  res.status(200).json({
    'XSRF-Token': csrfToken                    // Also return token in JSON response
  });
});

module.exports = router;

CSRF protection explained: Cross-Site Request Forgery (CSRF) is an attack that tricks users into submitting requests they didn't intend. Our protection works in two parts:

  1. The server sends a unique token to the client (in the XSRF-TOKEN cookie)
  2. The client must include this token in the header of all non-GET requests

The /api/csrf/restore route allows the frontend to get a fresh token if needed.

12. Set Up User Model and Migration

Create a User model and migration:

npx sequelize model:generate --name User --attributes username:string,email:string,hashedPassword:string

This command creates:

Update the User model in db/models/user.js:

// backend/db/models/user.js
'use strict';
const { Model, Validator } = require('sequelize');
const bcrypt = require('bcryptjs');

module.exports = (sequelize, DataTypes) => {
  class User extends Model {
    toSafeObject() {
      const { id, username, email } = this; // context will be the User instance
      return { id, username, email };
    }

    validatePassword(password) {
      return bcrypt.compareSync(password, this.hashedPassword.toString());
    }

    static getCurrentUserById(id) {
      return User.scope("currentUser").findByPk(id);
    }

    static async login({ credential, password }) {
      const { Op } = require('sequelize');
      const user = await User.scope('loginUser').findOne({
        where: {
          [Op.or]: {
            username: credential,
            email: credential
          }
        }
      });
      if (user && user.validatePassword(password)) {
        return await User.scope('currentUser').findByPk(user.id);
      }
    }

    static async signup({ username, email, password }) {
      const hashedPassword = bcrypt.hashSync(password);
      const user = await User.create({
        username,
        email,
        hashedPassword
      });
      return await User.scope('currentUser').findByPk(user.id);
    }

    static associate(models) {
      // define association here
    }
  }
  
  User.init({
    username: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        len: [4, 30],
        isNotEmail(value) {
          if (Validator.isEmail(value)) {
            throw new Error("Cannot be an email.");
          }
        }
      }
    },
    email: {
      type: DataTypes.STRING,
      allowNull: false,
      validate: {
        len: [3, 256],
        isEmail: true
      }
    },
    hashedPassword: {
      type: DataTypes.STRING.BINARY,
      allowNull: false,
      validate: {
        len: [60, 60]
      }
    }
  }, {
    sequelize,
    modelName: 'User',
    defaultScope: {
      attributes: {
        exclude: ['hashedPassword', 'email', 'createdAt', 'updatedAt']
      }
    },
    scopes: {
      currentUser: {
        attributes: { exclude: ['hashedPassword'] }
      },
      loginUser: {
        attributes: {}
      }
    }
  });
  return User;
};

Update the migration file in db/migrations/XXXXXXXXXXXXXX-create-user.js:

// backend/db/migrations/XXXXXXXXXXXXXX-create-user.js
'use strict';

let options = {};
if (process.env.NODE_ENV === 'production') {
  options.schema = process.env.SCHEMA;  // define your schema in options object
}

module.exports = {
  up: async (queryInterface, Sequelize) => {
    return queryInterface.createTable('Users', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      username: {
        type: Sequelize.STRING(30),
        allowNull: false,
        unique: true
      },
      email: {
        type: Sequelize.STRING(256),
        allowNull: false,
        unique: true
      },
      hashedPassword: {
        type: Sequelize.STRING.BINARY,
        allowNull: false
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
        defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
        defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
      }
    }, options);
  },
  down: async (queryInterface, Sequelize) => {
    options.tableName = "Users";
    return queryInterface.dropTable(options);
  }
};

Install bcryptjs for password hashing:

npm install bcryptjs

User model security features:

  • Password hashing: Passwords are hashed using bcrypt before storage
  • Validation: Username and email have validation rules
  • Scopes: Different scopes control which attributes are visible
  • Safe objects: The toSafeObject method returns only non-sensitive data

These features work together to ensure proper security for user authentication.

13. Create User Seeds

Create a seed file for adding demo users:

npx sequelize seed:generate --name demo-user

Update the seed file in db/seeders/XXXXXXXXXXXXXX-demo-user.js:

// backend/db/seeders/XXXXXXXXXXXXXX-demo-user.js
'use strict';
const bcrypt = require('bcryptjs');

let options = {};
if (process.env.NODE_ENV === 'production') {
  options.schema = process.env.SCHEMA;  // define your schema in options object
}

module.exports = {
  up: async (queryInterface, Sequelize) => {
    options.tableName = 'Users';
    return queryInterface.bulkInsert(options, [
      {
        email: 'demo@user.io',
        username: 'Demo-lition',
        hashedPassword: bcrypt.hashSync('password')
      },
      {
        email: 'user1@user.io',
        username: 'FakeUser1',
        hashedPassword: bcrypt.hashSync('password2')
      },
      {
        email: 'user2@user.io',
        username: 'FakeUser2',
        hashedPassword: bcrypt.hashSync('password3')
      }
    ], {});
  },

  down: async (queryInterface, Sequelize) => {
    options.tableName = 'Users';
    const Op = Sequelize.Op;
    return queryInterface.bulkDelete(options, {
      username: { [Op.in]: ['Demo-lition', 'FakeUser1', 'FakeUser2'] }
    }, {});
  }
};

Seed data purpose: Seed data provides initial data for development and testing. These demo users allow you to test authentication features without having to create new users manually each time.

14. Run Migrations and Seeds

Run the migration to create the Users table:

npx dotenv sequelize db:migrate

Run the seed to add demo users:

npx dotenv sequelize db:seed:all

Database initialization: These commands initialize your database with:

  1. The table structure defined in your migrations
  2. Initial data defined in your seeders

The dotenv prefix ensures environment variables from your .env file are loaded.

15. Add Authentication Middleware

Create a utils folder in the backend directory for middleware functions:

mkdir -p backend/utils

Create the auth.js file in the utils folder:

touch backend/utils/auth.js

Add the following content to auth.js:

// backend/utils/auth.js
const jwt = require('jsonwebtoken');
const { jwtConfig } = require('../config');
const { User } = require('../db/models');

const { secret, expiresIn } = jwtConfig;

// Send a JWT Cookie
const setTokenCookie = (res, user) => {
  // Create the token
  const token = jwt.sign(
    { data: user.toSafeObject() },
    secret,
    { expiresIn: parseInt(expiresIn) } // 604,800 seconds = 1 week
  );

  const isProduction = process.env.NODE_ENV === "production";

  // Set the token cookie
  res.cookie('token', token, {
    maxAge: expiresIn * 1000, // maxAge in milliseconds
    httpOnly: true,
    secure: isProduction,
    sameSite: isProduction && "Lax"
  });

  return token;
};

// Restore the user from a JWT token
const restoreUser = (req, res, next) => {
  // token parsed from cookies
  const { token } = req.cookies;
  req.user = null;

  return jwt.verify(token, secret, null, async (err, jwtPayload) => {
    if (err) {
      return next();
    }

    try {
      const { id } = jwtPayload.data;
      req.user = await User.getCurrentUserById(id);
    } catch (e) {
      res.clearCookie('token');
      return next();
    }

    if (!req.user) res.clearCookie('token');

    return next();
  });
};

// Require user to be authenticated
const requireAuth = function (req, _res, next) {
  if (req.user) return next();

  const err = new Error('Authentication required');
  err.title = 'Authentication required';
  err.errors = { message: 'Authentication required' };
  err.status = 401;
  return next(err);
};

module.exports = { setTokenCookie, restoreUser, requireAuth };

Authentication middleware explanation:

  • setTokenCookie: Creates a JWT and sets it in an HTTP-only cookie
  • restoreUser: Verifies the JWT and loads the user from the database
  • requireAuth: Used to protect routes that require authentication

These middleware functions work together to implement JWT-based authentication.

16. Set Up Server

Create a bin folder and www file to start the server:

mkdir -p backend/bin
touch backend/bin/www

Add the following content to bin/www:

#!/usr/bin/env node
// backend/bin/www

// Import environment variables
require('dotenv').config();                     // Load variables from .env file

const { port } = require('../config');

const app = require('../app');
const db = require('../db/models');

// Check the database connection before starting the app
db.sequelize
  .authenticate()                               // Test database connection
  .then(() => {
    console.log('Database connection success! Sequelize is ready to use...');

    // Start listening for connections
    app.listen(port, () => console.log(`Listening on port ${port}...`));
  })
  .catch((err) => {
    console.log('Database connection failure.');
    console.error(err);
  });

Server startup sequence: This file follows a best practice of checking for database connectivity before starting the server. This prevents the server from starting if the database is unavailable, which would lead to runtime errors later.

The #!/usr/bin/env node shebang allows the file to be run directly from the command line on Unix-like systems.

17. Configure Package.json Scripts

Update your backend/package.json file to include the following scripts:

"scripts": {
  "sequelize": "sequelize",                    // Run Sequelize CLI directly
  "sequelize-cli": "sequelize-cli",            // Alternative way to run Sequelize CLI
  "start": "per-env",                          // Runs different scripts based on NODE_ENV
  "start:development": "nodemon ./bin/www",    // Development: auto-restart on changes
  "start:production": "node ./bin/www",        // Production: regular node process
  "build": "node psql-setup-script.js"         // Used during deployment for database setup
}

npm scripts benefits: These scripts provide convenient shortcuts for common operations:

  • Using per-env allows different start commands based on the environment
  • nodemon in development automatically restarts the server when files change
  • The build script is used by deployment platforms like Heroku to set up the database

18. Test the Server

Start the server and test the hello world route:

cd backend
npm start

Open your browser and navigate to http://localhost:8000/hello/world. You should see "Hello World!" displayed, and two cookies (_csrf and XSRF-TOKEN) should be set in your browser.

Review the Solution

At this point, you have successfully set up the backend of your authentication application. Let's review what you've accomplished:

Your application is now ready for implementing API routes, which we'll cover in Phase 1.

Common Issues and Solutions

Security considerations: This setup implements several important security best practices:

  • CSRF protection to prevent cross-site request forgery
  • Secure cookie settings (httpOnly, sameSite, secure in production)
  • Security headers via Helmet
  • Environment separation for development vs. production
  • Secret keys stored in environment variables
  • Password hashing with bcrypt
  • Model validation to enforce data constraints

These measures provide a solid foundation for building a secure authentication system.

Next Steps

Now that you have set up the backend of your application, you are ready to move on to Phase 1: API Routes, where you will create API endpoints for your application.

Looking ahead: In the next phase, you'll implement user authentication routes including:

  • User registration (signup)
  • User login
  • User logout
  • Get current user session
  • Authentication middleware to protect routes

The foundation you've built here will make those implementations more straightforward. With the User model, migration, and seed data in place, you now have a solid base for building authentication functionality.