Express.js dan JWT

Welcome back, AnakInformatika! In today’s digital landscape, building modern web applications almost always involves using a REST API as the bridge between the frontend and backend. However, functionality alone isn't enough; security is a non-negotiable foundation.

Imagine building an app that handles sensitive user data. Without proper safeguards, that data is vulnerable to unauthorized access. In this tutorial, we will walk you through a detailed guide on How to Create a Secure REST API using Express.js and JWT (JSON Web Tokens)—a powerful combination for robust authentication and authorization.

We will cover everything from project setup and secure registration with bcrypt password hashing to implementing JWT to protect your API routes. Ready to build an API that is both functional and super secure? Let’s dive in!


Prerequisites

Before we jump into the code, ensure you have the following:

  • Node.js & npm: Installed on your system (Download from Node.js official site).

  • Basic JavaScript & Express.js Knowledge: Familiarity with ES6 syntax and Express concepts like routing and middleware.

  • Code Editor: Visual Studio Code is highly recommended.

  • API Testing Tool: Postman or Insomnia.

  • MongoDB: Local installation or a free MongoDB Atlas account.


Step 1: Project Initialization and Dependencies

First, let's set up the project folder and install the necessary libraries.

1.1 Create a New Project Folder

Open your terminal and run:

Bash
 
mkdir secure-api-jwt
cd secure-api-jwt

1.2 Initialize Node.js

Bash
 
npm init -y

1.3 Install Dependencies

Bash
 
npm install express jsonwebtoken bcryptjs dotenv mongoose
  • express: Web framework for Node.js.

  • jsonwebtoken: To generate and verify JWTs.

  • bcryptjs: To hash passwords securely.

  • dotenv: To manage environment variables.

  • mongoose: ODM (Object Data Modeling) for MongoDB.


Step 2: Configure Environment Variables (.env)

Never hardcode sensitive data like JWT secrets or database URIs. Create a .env file in your root directory:

Cuplikan kode
 
PORT=5000
JWT_SECRET=your_super_strong_random_secret_key
MONGO_URI=mongodb://localhost:27017/secure_api_db

Note: Replace your_super_strong_random_secret_key with a long, complex string.


Step 3: MongoDB Database Connection

Create a config/db.js file to handle the connection logic:

JavaScript
 
// config/db.js
const mongoose = require('mongoose');
require('dotenv').config();

const connectDB = async () => {
    try {
        await mongoose.connect(process.env.MONGO_URI);
        console.log('MongoDB Connected...');
    } catch (err) {
        console.error(err.message);
        process.exit(1);
    }
};

module.exports = connectDB;

Step 4: Define the User Model

We need a schema to define our users and handle password encryption. Create models/User.js:

JavaScript
 
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const UserSchema = new mongoose.Schema({
    username: { type: String, required: true, unique: true },
    password: { type: String, required: true }
});

// Hash password before saving to database
UserSchema.pre('save', async function(next) {
    if (!this.isModified('password')) return next();
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
    next();
});

// Method to compare passwords
UserSchema.methods.comparePassword = async function(candidatePassword) {
    return await bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model('User', UserSchema);

Step 5: Create JWT Authentication Middleware

This middleware will intercept requests to protected routes to verify the token. Create middleware/auth.js:

JavaScript
 
// middleware/auth.js
const jwt = require('jsonwebtoken');
require('dotenv').config();

module.exports = function(req, res, next) {
    const token = req.header('x-auth-token');

    if (!token) {
        return res.status(401).json({ msg: 'No token, authorization denied' });
    }

    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        req.user = decoded.user;
        next();
    } catch (err) {
        res.status(401).json({ msg: 'Token is not valid' });
    }
};

Step 6: Create Authentication Routes (Register & Login)

Create routes/auth.js to handle user registration and login logic. This is where we generate the JWT and send it back to the client.


// routes/auth.js
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const User = require('../models/User');
require('dotenv').config();

// @route   POST api/auth/register
// @desc    Mendaftarkan user baru
// @access  Public
router.post('/register', async (req, res) => {
    const { username, password } = req.body;

    try {
        let user = await User.findOne({ username });
        if (user) {
            return res.status(400).json({ msg: 'User sudah terdaftar' });
        }

        user = new User({
            username,
            password
        });

        await user.save(); // Password akan di-hash secara otomatis oleh middleware pre('save') di model User

        // Buat payload untuk JWT
        const payload = {
            user: {
                id: user.id
            }
        };

        // Buat token
        jwt.sign(
            payload,
            process.env.JWT_SECRET,
            { expiresIn: '1h' }, // Token akan kadaluarsa dalam 1 jam
            (err, token) => {
                if (err) throw err;
                res.json({ token });
            }
        );

    } catch (err) {
        console.error(err.message);
        res.status(500).send('Server Error');
    }
});

// @route   POST api/auth/login
// @desc    Login user & mendapatkan token
// @access  Public
router.post('/login', async (req, res) => {
    const { username, password } = req.body;

    try {
        let user = await User.findOne({ username });
        if (!user) {
            return res.status(400).json({ msg: 'Kredensial tidak valid' });
        }

        const isMatch = await user.comparePassword(password);
        if (!isMatch) {
            return res.status(400).json({ msg: 'Kredensial tidak valid' });
        }

        // Buat payload untuk JWT
        const payload = {
            user: {
                id: user.id
            }
        };

        // Buat token
        jwt.sign(
            payload,
            process.env.JWT_SECRET,
            { expiresIn: '1h' },
            (err, token) => {
                if (err) throw err;
                res.json({ token });
            }
        );

    } catch (err) {
        console.error(err.message);
        res.status(500).send('Server Error');
    }
});

module.exports = router;

Code Explanation:

1. Registration Flow

  • Request Handling: Receives username and password from the request body.

  • Duplication Check: Queries the database to see if the username is already taken.

  • Automated Hashing: If unique, it creates a new user. The password is automatically hashed before saving, thanks to the pre('save') middleware we defined in the User model.

  • Token Generation: Generates a JWT using jwt.sign(). The payload contains only the user.id to keep the token lightweight and secure.

  • Expiration: Sets the token to expire in 1 hour for better security.

2. Login Flow

  • User Verification: Checks if the username exists in the database.

  • Password Comparison: Uses the custom user.comparePassword() method to verify if the provided password matches the stored hash.

  • Authentication: If credentials are valid, it issues a new JWT, allowing the user to access protected routes.

 


Step 7: Create Protected Routes

Create routes/protected.js to define endpoints that require a valid token to access:

JavaScript
 
// Example of a private route
router.get('/me', auth, async (req, res) => {
    const user = await User.findById(req.user.id).select('-password');
    res.json(user);
});

Step 8: Main Server Setup (app.js)

Assemble everything in your main entry point, app.js:

JavaScript
 
const express = require('express');
const connectDB = require('./config/db');
const app = express();

connectDB();
app.use(express.json());

app.use('/api/auth', require('./routes/auth'));
app.use('/api/protected', require('./routes/protected'));

const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

Best Practices for API Security

Building the API is just the start. To make it production-ready, consider these Security Best Practices:

  1. JWT Security: Use a strong secret key and set a reasonable expiration time (e.g., 1 hour). Consider implementing Refresh Tokens for better UX.

  2. HTTPS/SSL: Always serve your API over HTTPS to prevent "Man-in-the-Middle" attacks.

  3. Input Validation: Use libraries like express-validator to sanitize and validate user input.

  4. Rate Limiting: Use express-rate-limit to prevent brute-force attacks on your login endpoints.

  5. CORS Configuration: Only allow trusted domains to access your API using the cors middleware.

Conclusion

Congratulations! You’ve successfully implemented a Secure REST API with Express.js and JWT. You’ve learned how to hash passwords, manage sessions with tokens, and protect sensitive data.

Security is a continuous journey—stay updated with the latest threats and keep experimenting. Happy coding, AnakInformatika!