Hi everyone! If you’re a developer looking to build your own authentication system from scratch, you’re in the right place. In this post, I’ll walk you through how I built a simple but secure authentication system using React for the frontend, Node.js and Express for the backend, and PostgreSQL for the database. I’ll also reference my GitHub repository throughout, so you can follow along with the actual code.
Why Build Your Own Authentication System?
Before we dive in, you might wonder why not just use an existing service like Firebase or Auth0. While those are solid choices, building your own system helps you understand the fundamentals of authentication—things like password hashing, JSON Web Tokens (JWT), and secure API design. It also gives you full control over user management and customization.
If you’re just getting started or want to gain a deeper understanding of how authentication works under the hood, this project is a great learning opportunity.
The Tech Stack
Here’s a quick look at the technologies used in this project:
- React: For building the frontend UI
- Node.js: To run JavaScript on the server
- Express: A lightweight framework for building APIs
- PostgreSQL: Our relational database
- bcryptjs: For password hashing
- jsonwebtoken: For generating and verifying JWTs
1. Setting Up the Backend
Start by setting up the backend. Create a new folder and initialize a Node.js project:
mkdir auth-system
cd auth-system
npm init -y
Install the required dependencies:
npm install express pg bcryptjs jsonwebtoken dotenv cors
These libraries will help you create the API, connect to PostgreSQL, hash passwords, handle authentication, and manage environment variables.
Connecting to PostgreSQL
Create a db.js
file for the database connection:
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT,
});
module.exports = pool;
Add your database credentials to a .env
file:
DB_USER=your_username
DB_HOST=localhost
DB_NAME=auth_system
DB_PASSWORD=your_password
DB_PORT=5432
JWT_SECRET=your_jwt_secret
Creating the Users Table
Run the following SQL command in your database to create the users
table:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
2. Handling User Registration
Create a POST endpoint to handle user sign-ups:
const express = require('express');
const bcrypt = require('bcryptjs');
const pool = require('./db');
const cors = require('cors');
const app = express();
app.use(express.json());
app.use(cors());
app.post('/api/auth/register', async (req, res) => {
const { email, password } = req.body;
try {
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
const result = await pool.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
[email, hashedPassword]
);
res.status(201).json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
3. Handling User Login
Create another POST endpoint to verify credentials and issue a JWT:
const jwt = require('jsonwebtoken');
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
try {
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
if (result.rows.length === 0) {
return res.status(400).json({ error: 'User not found' });
}
const user = result.rows[0];
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
return res.status(400).json({ error: 'Invalid password' });
}
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' });
res.json({ token, user: { id: user.id, email: user.email } });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
4. Protecting Routes
Add middleware to validate JWTs for protected routes:
const authenticate = (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) return res.status(401).send('Access denied');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
res.status(400).send('Invalid token');
}
};
app.get('/api/profile', authenticate, async (req, res) => {
try {
const result = await pool.query('SELECT * FROM users WHERE id = $1', [req.user.id]);
res.json(result.rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
5. Building the Frontend with React
Create a React frontend using Vite:
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install axios react-router-dom
Here’s a simple login component example:
import React, { useState } from 'react';
import axios from 'axios';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await axios.post('http://localhost:3001/api/auth/login', {
email,
password,
});
localStorage.setItem('token', response.data.token);
// Navigate or update UI as needed
} catch {
setError('Login failed. Please try again.');
}
};
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
<button type="submit">Login</button>
{error && <p>{error}</p>}
</form>
);
};
export default Login;
6. Protecting Frontend Routes
Use a wrapper component to guard private routes:
import { Navigate } from 'react-router-dom';
const PrivateRoute = ({ children }: { children: JSX.Element }) => {
const token = localStorage.getItem('token');
return token ? children : <Navigate to="/login" />;
};
Then use it in your route definitions:
<Route path="/profile" element={<PrivateRoute><Profile /></PrivateRoute>} />
7. Testing the System
- Register a new user using the register form.
- Log in with the new credentials.
- Attempt to access protected routes like
/profile
and see if they are blocked without a token.
8. Security Tips
- Always hash passwords before storing them.
- Use HTTPS in production to encrypt network traffic.
- Set expiration times on JWTs to reduce security risks.
- Validate and sanitize user input.
- Keep secret keys in environment variables, not in your code.
9. What to Build Next
Once the basics are done, you can add:
- Password reset functionality
- Email verification
- Social login (Google, Facebook)
- Role-based access (admin, user, etc.)
10. Final Thoughts
Building an authentication system from scratch helps you truly understand how user authentication works. It's more than just writing code—it's about understanding how to protect user data, manage sessions, and structure secure applications.
Check out the full source code on GitHub. I’ve done my best to keep it clean and beginner-friendly. If you have questions or suggestions, feel free to open an issue or connect with me there.
Thanks for reading and good luck with your project.