Understanding the Problem
In our Express application, we need to create API routes that handle user authentication. These routes will be the interface between the frontend client and our backend authentication system. This is an essential part of any web application that requires user accounts and secured access.
Let's once again apply George Polya's problem-solving approach to build our API routes:
Step 1: Understand the Problem
We need to create REST API endpoints that will:
- Allow users to sign up with new accounts
- Let users log in with existing credentials
- Allow logged-in users to log out
- Provide information about the current user session
- Validate user input for security
- Follow RESTful conventions
Step 2: Devise a Plan
- Set up an API router structure
- Create session-related routes (login, logout, current session)
- Create user-related routes (signup)
- Implement request validation
- Connect these routes to our main application
- Test all routes thoroughly
Step 3: Execute the Plan
Let's start implementing our API routes:
Setting Up API Router Structure
Creating the API Router Base
First, we'll create a base router file that will serve as the entry point for all API routes. This router will be mounted at the path /api.
// File: backend/routes/api/index.js
const router = require('express').Router();
const { restoreUser } = require('../../utils/auth.js');
// Connect restoreUser middleware to the API router
// This will check for a valid token and set req.user
router.use(restoreUser);
// Test route for verifying API setup and CSRF protection
router.post('/test', function(req, res) {
res.json({ requestBody: req.body });
});
module.exports = router;
This base file does two important things:
- Applies the
restoreUsermiddleware to all API routes, which will populatereq.userif a valid session token exists - Includes a test route at
/api/testthat we can use to verify our setup is working correctly
Connecting the API Router to the Main Application
Next, we need to connect this API router to our main routes file:
// File: backend/routes/index.js
const express = require('express');
const router = express.Router();
const apiRouter = require('./api');
// CSRF token restoration route
router.get("/api/csrf/restore", (req, res) => {
const csrfToken = req.csrfToken();
res.cookie("XSRF-TOKEN", csrfToken);
res.status(200).json({
'XSRF-Token': csrfToken
});
});
// Connect all API routes
router.use('/api', apiRouter);
module.exports = router;
This structure gives us a clean separation of concerns: the base routes/index.js handles top-level routes (like CSRF token restoration), while the API-specific routes are organized in the routes/api directory.
Testing the API Router Setup
Before continuing, let's test our API router setup by making a request to the test endpoint:
// First, get a CSRF token
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
// Then, test the API endpoint
return fetch('/api/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'XSRF-TOKEN': data['XSRF-Token']
},
body: JSON.stringify({ hello: 'world' })
});
})
.then(res => res.json())
.then(data => console.log(data));
If everything is set up correctly, you should see { requestBody: { hello: 'world' } } logged to the console.
Creating Session Routes
Setting Up the Session Router
Now, let's create a dedicated router for session management. This will handle login, logout, and retrieving the current session:
// File: backend/routes/api/session.js
const express = require('express');
const { Op } = require('sequelize');
const bcrypt = require('bcryptjs');
const { setTokenCookie, restoreUser } = require('../../utils/auth');
const { User } = require('../../db/models');
const router = express.Router();
module.exports = router;
Login Route
The login route will accept a credential (username or email) and password, verify them, and establish a session if valid:
// File: backend/routes/api/session.js
// ... (previous code)
// Log in
router.post('/', async (req, res, next) => {
const { credential, password } = req.body;
// Find the user with the provided credential (username or email)
const user = await User.unscoped().findOne({
where: {
[Op.or]: {
username: credential,
email: credential
}
}
});
// Verify password and handle invalid credentials
if (!user || !bcrypt.compareSync(password, user.hashedPassword.toString())) {
const err = new Error('Login failed');
err.status = 401;
err.title = 'Login failed';
err.errors = { credential: 'The provided credentials were invalid.' };
return next(err);
}
// Create a safe user object without sensitive information
const safeUser = {
id: user.id,
email: user.email,
username: user.username,
firstName: user.firstName,
lastName: user.lastName
};
// Set the JWT token as a cookie
await setTokenCookie(res, safeUser);
// Return the user information in the response
return res.json({
user: safeUser
});
});
The login route demonstrates several important concepts:
- Flexible credential acceptance: Users can log in with either their username or email
- Password verification: We use bcrypt to securely compare the submitted password with the hashed password in the database
- User information protection: We create a "safe" user object that excludes sensitive information like the hashed password
- JWT-based session: We set a token cookie that will be used for subsequent authenticated requests
Logout Route
The logout route simply clears the session token cookie:
// File: backend/routes/api/session.js
// ... (previous code)
// Log out
router.delete('/', (_req, res) => {
res.clearCookie('token');
return res.json({ message: 'success' });
});
Get Current Session Route
This route returns information about the current user session, if one exists:
// File: backend/routes/api/session.js
// ... (previous code)
// Restore session user
router.get('/', (req, res) => {
const { user } = req;
if (user) {
const safeUser = {
id: user.id,
email: user.email,
username: user.username,
firstName: user.firstName,
lastName: user.lastName
};
return res.json({
user: safeUser
});
} else return res.json({ user: null });
});
This route leverages the restoreUser middleware we applied to all API routes, which sets req.user if a valid session exists.
Creating User Routes
Setting Up the Users Router
Now, let's create a dedicated router for user-related operations, starting with signup:
// File: backend/routes/api/users.js
const express = require('express');
const bcrypt = require('bcryptjs');
const { setTokenCookie, requireAuth } = require('../../utils/auth');
const { User } = require('../../db/models');
const router = express.Router();
module.exports = router;
Signup Route
The signup route creates a new user and establishes a session:
// File: backend/routes/api/users.js
// ... (previous code)
// Sign up
router.post('/', async (req, res) => {
const { email, password, username, firstName, lastName } = req.body;
// Hash the password for storage
const hashedPassword = bcrypt.hashSync(password);
// Create the user in the database
const user = await User.create({
email,
username,
hashedPassword,
firstName,
lastName
});
// Create a safe user object without sensitive information
const safeUser = {
id: user.id,
email: user.email,
username: user.username,
firstName: user.firstName,
lastName: user.lastName
};
// Set the JWT token as a cookie
await setTokenCookie(res, safeUser);
// Return the user information
return res.json({
user: safeUser
});
});
The signup route demonstrates these key concepts:
- Password security: We hash the user's password before storing it
- Automatic login: After signup, the user is automatically logged in via a JWT token
- Information protection: We return only non-sensitive user information
Connecting the Routers
Now, let's connect these routers to our API router:
// File: backend/routes/api/index.js
const router = require('express').Router();
const sessionRouter = require('./session.js');
const usersRouter = require('./users.js');
const { restoreUser } = require("../../utils/auth.js");
// Connect restoreUser middleware to the API router
router.use(restoreUser);
// Connect session and user routers
router.use('/session', sessionRouter);
router.use('/users', usersRouter);
// Test route
router.post('/test', (req, res) => {
res.json({ requestBody: req.body });
});
module.exports = router;
With this structure, our API routes are now organized as follows:
/api/session(POST) - Log in/api/session(DELETE) - Log out/api/session(GET) - Get current session/api/users(POST) - Sign up/api/test(POST) - Test endpoint
Implementing Request Validation
Creating Validation Middleware
To enhance security and provide better user feedback, let's add validation to our routes using express-validator:
// File: backend/utils/validation.js
const { validationResult } = require('express-validator');
// middleware for formatting errors from express-validator middleware
const handleValidationErrors = (req, _res, next) => {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
const errors = {};
validationErrors
.array()
.forEach(error => errors[error.path] = error.msg);
const err = Error("Bad request.");
err.errors = errors;
err.status = 400;
err.title = "Bad request.";
next(err);
}
next();
};
module.exports = {
handleValidationErrors
};
Adding Login Validation
Let's add validation to our login route:
// File: backend/routes/api/session.js
// ... (imports)
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');
// ... (other code)
// Validate login inputs
const validateLogin = [
check('credential')
.exists({ checkFalsy: true })
.notEmpty()
.withMessage('Please provide a valid email or username.'),
check('password')
.exists({ checkFalsy: true })
.withMessage('Please provide a password.'),
handleValidationErrors
];
// Apply validation to the login route
router.post(
'/',
validateLogin,
async (req, res, next) => {
// ... (login route handler)
}
);
Adding Signup Validation
Similarly, let's add validation to our signup route:
// File: backend/routes/api/users.js
// ... (imports)
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');
// ... (other code)
// Validate signup inputs
const validateSignup = [
check('email')
.exists({ checkFalsy: true })
.isEmail()
.withMessage('Please provide a valid email.'),
check('username')
.exists({ checkFalsy: true })
.isLength({ min: 4 })
.withMessage('Please provide a username with at least 4 characters.'),
check('username')
.not()
.isEmail()
.withMessage('Username cannot be an email.'),
check('password')
.exists({ checkFalsy: true })
.isLength({ min: 6 })
.withMessage('Password must be 6 characters or more.'),
check('firstName')
.exists({ checkFalsy: true })
.withMessage('Please provide a first name.'),
check('lastName')
.exists({ checkFalsy: true })
.withMessage('Please provide a last name.'),
handleValidationErrors
];
// Apply validation to the signup route
router.post(
'/',
validateSignup,
async (req, res) => {
// ... (signup route handler)
}
);
These validation checks provide several benefits:
- Input sanitization: They ensure that the data we receive meets our requirements
- Helpful error messages: They provide clear feedback about what went wrong
- Security enhancement: They help prevent malicious inputs
Testing API Routes
Testing Login
Let's test our login route with both valid and invalid credentials:
// Get a CSRF token first
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
// Test login with username
return fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": csrfToken
},
body: JSON.stringify({ credential: 'Demo-lition', password: 'password' })
});
})
.then(res => res.json())
.then(data => console.log("Login with username:", data))
.catch(err => console.error("Login error:", err));
// Test login with email
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": csrfToken
},
body: JSON.stringify({ credential: 'demo@user.io', password: 'password' })
});
})
.then(res => res.json())
.then(data => console.log("Login with email:", data))
.catch(err => console.error("Login error:", err));
// Test login with invalid credentials
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/session', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": csrfToken
},
body: JSON.stringify({ credential: 'Demo-lition', password: 'wrongpassword' })
});
})
.then(res => res.json())
.then(data => console.log("Invalid login:", data))
.catch(err => console.error("Login error:", err));
Testing Signup
Let's test our signup route:
// Test signup
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/users', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": csrfToken
},
body: JSON.stringify({
email: 'new-user@example.com',
username: 'NewUser',
password: 'password123',
firstName: 'New',
lastName: 'User'
})
});
})
.then(res => res.json())
.then(data => console.log("Signup:", data))
.catch(err => console.error("Signup error:", err));
// Test signup with validation errors
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/users', {
method: 'POST',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": csrfToken
},
body: JSON.stringify({
email: 'invalid-email',
username: 'a',
password: 'short',
firstName: '',
lastName: ''
})
});
})
.then(res => res.json())
.then(data => console.log("Invalid signup:", data))
.catch(err => console.error("Signup error:", err));
Testing Logout
Let's test our logout route:
// Test logout (make sure you're logged in first)
fetch('/api/csrf/restore')
.then(res => res.json())
.then(data => {
const csrfToken = data['XSRF-Token'];
return fetch('/api/session', {
method: 'DELETE',
headers: {
"Content-Type": "application/json",
"XSRF-TOKEN": csrfToken
}
});
})
.then(res => res.json())
.then(data => console.log("Logout:", data))
.catch(err => console.error("Logout error:", err));
Testing Get Current Session
Finally, let's test our get current session route:
// Test get current session
fetch('/api/session')
.then(res => res.json())
.then(data => console.log("Current session:", data))
.catch(err => console.error("Session error:", err));
Understanding RESTful API Design
Step 4: Review the Solution
Let's review what we've accomplished and understand the principles behind our API design:
RESTful Resource Modeling
We've structured our API around two key resources:
- Session resource: Represents the user's authenticated session
- Users resource: Represents user accounts
We've used HTTP verbs to represent actions on these resources:
- POST /api/session: Create a new session (login)
- DELETE /api/session: Delete the current session (logout)
- GET /api/session: Retrieve the current session
- POST /api/users: Create a new user (signup)
Consistent Response Format
Our API returns responses in a consistent JSON format:
- Success responses include the relevant data (like user information)
- Error responses include a title, message, and specific errors
Security Considerations
Our API includes several security measures:
- CSRF protection: We require a CSRF token for non-GET requests
- Input validation: We validate all user inputs
- Password hashing: We never store or transmit plain-text passwords
- JWT-based sessions: We use secure, HTTP-only cookies for session management
Real-World Applications
This type of API design is common in modern web applications:
- Single-Page Applications (SPAs): React, Vue, or Angular frontends commonly interact with RESTful APIs like this one
- Mobile Applications: Mobile apps can use the same API endpoints for authentication
- Third-Party Integrations: Well-designed APIs can be used by third-party services
Common Authentication Patterns
JWT Authentication
Our implementation uses JSON Web Tokens (JWTs) for authentication. Here's how it works:
- When a user logs in, we create a JWT containing their user ID and other non-sensitive information
- We set this JWT as an HTTP-only cookie, which makes it inaccessible to JavaScript
- On subsequent requests, the browser automatically sends this cookie
- Our
restoreUsermiddleware verifies the JWT and populatesreq.user - Protected routes can check
req.userto ensure the user is authenticated
Alternative Authentication Methods
While we've implemented JWT-based cookie authentication, there are other common approaches:
Bearer Token Authentication
In this approach, the client includes the token in the Authorization header of each request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
This is common in mobile apps and APIs that don't rely on cookies.
OAuth 2.0
OAuth allows users to authenticate using third-party providers like Google or Facebook. Our API could be extended to support this by:
- Adding routes for initiating OAuth flows
- Implementing callbacks to handle provider responses
- Creating or retrieving user accounts based on OAuth information
Multi-factor Authentication (MFA)
For higher security applications, we could add MFA by:
- Adding fields to the User model for MFA status and secrets
- Creating routes for setting up and verifying MFA
- Requiring MFA verification during the login process
Extending the API
Password Reset Functionality
A common extension to authentication systems is password reset functionality:
// Route to request a password reset
router.post('/password-reset', async (req, res) => {
const { email } = req.body;
// Find the user
const user = await User.findOne({ where: { email } });
if (!user) {
// Don't reveal whether a user exists
return res.json({ message: 'If an account with that email exists, a reset link has been sent.' });
}
// Generate a secure reset token and save it to the user
const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenExpiry = Date.now() + 3600000; // 1 hour
// Update user with reset token
await user.update({
resetToken,
resetTokenExpiry
});
// In a real app, you would send an email with the reset link
console.log(`Reset link: http://localhost:3000/reset-password?token=${resetToken}`);
return res.json({ message: 'If an account with that email exists, a reset link has been sent.' });
});
// Route to reset password with token
router.post('/password-reset/:token', async (req, res) => {
const { token } = req.params;
const { password } = req.body;
// Find user with this reset token and valid expiry
const user = await User.findOne({
where: {
resetToken: token,
resetTokenExpiry: {
[Op.gt]: Date.now()
}
}
});
if (!user) {
const err = new Error('Invalid or expired reset token');
err.status = 400;
return next(err);
}
// Update the user's password
const hashedPassword = bcrypt.hashSync(password);
await user.update({
hashedPassword,
resetToken: null,
resetTokenExpiry: null
});
return res.json({ message: 'Password updated successfully' });
});
User Profile Management
We could add routes for managing user profiles:
// Get the current user's profile
router.get('/profile', requireAuth, async (req, res) => {
// req.user is set by the restoreUser middleware
const user = await User.findByPk(req.user.id, {
attributes: ['id', 'username', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt']
});
return res.json({ user });
});
// Update the current user's profile
router.put('/profile', requireAuth, async (req, res) => {
const { firstName, lastName } = req.body;
// Update user information
const user = await User.findByPk(req.user.id);
await user.update({
firstName,
lastName
});
const safeUser = {
id: user.id,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName
};
return res.json({ user: safeUser });
});
Email Verification
For enhanced security, we could add email verification:
// Add fields to User model for email verification
// emailVerified: Boolean
// emailVerificationToken: String
// Send verification email on signup
router.post('/', validateSignup, async (req, res) => {
// ... (existing signup code)
// Generate verification token
const verificationToken = crypto.randomBytes(32).toString('hex');
user.emailVerificationToken = verificationToken;
await user.save();
// In a real app, send an email with the verification link
console.log(`Verification link: http://localhost:3000/verify-email?token=${verificationToken}`);
// ... (rest of signup code)
});
// Route to verify email
router.get('/verify-email/:token', async (req, res) => {
const { token } = req.params;
// Find user with this verification token
const user = await User.findOne({
where: { emailVerificationToken: token }
});
if (!user) {
const err = new Error('Invalid verification token');
err.status = 400;
return next(err);
if (!user) {
const err = new Error('Invalid verification token');
err.status = 400;
return next(err);
}
// Mark email as verified and clear the token
await user.update({
emailVerified: true,
emailVerificationToken: null
});
return res.json({ message: 'Email verified successfully' });
});
Advanced Authentication Topics
Role-Based Access Control
For applications that need different permission levels, we could implement role-based access control:
- Add a 'role' field to the User model (e.g., 'user', 'admin', 'moderator')
- Create middleware to check roles:
// Middleware to check if user has required role
const requireRole = (role) => {
return (req, res, next) => {
if (!req.user) {
const err = new Error('Authentication required');
err.status = 401;
return next(err);
}
if (req.user.role !== role) {
const err = new Error('Forbidden');
err.status = 403;
err.title = 'Forbidden';
err.errors = { message: 'You do not have permission to perform this action' };
return next(err);
}
return next();
};
};
// Example usage on a route
router.delete('/users/:userId', requireAuth, requireRole('admin'), async (req, res) => {
// Only admins can access this route
// ... route implementation
});
Session Management
For more advanced session management, we might add:
- Session invalidation: Allow users to invalidate all active sessions
- Session tracking: Keep track of active sessions across devices
- Session activity: Record last activity time for security purposes
// Add route to invalidate all sessions
router.post('/session/invalidate-all', requireAuth, async (req, res) => {
// Update the user's record with a new token version
// This would require modifying our JWT verification to check this version
const user = await User.findByPk(req.user.id);
await user.update({
tokenVersion: (user.tokenVersion || 0) + 1
});
// Clear the current session cookie
res.clearCookie('token');
return res.json({ message: 'All sessions have been invalidated' });
});
API Rate Limiting
To prevent abuse, we could add rate limiting to authentication endpoints:
// Using express-rate-limit package
const rateLimit = require('express-rate-limit');
// Create a limiter for login attempts
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
message: {
message: 'Too many login attempts from this IP, please try again after 15 minutes'
},
standardHeaders: true,
legacyHeaders: false,
});
// Apply to login route
router.post('/', loginLimiter, validateLogin, async (req, res, next) => {
// ... existing login handler
});
Best Practices for API Security
Secure Password Storage
Our implementation uses bcrypt for password hashing, which is a good practice because:
- It automatically includes a salt to protect against rainbow table attacks
- It's computationally expensive, making brute force attacks difficult
- It's adaptive - work factor can be increased as hardware gets faster
HTTPS
In production, always ensure your API is served over HTTPS to prevent:
- Man-in-the-middle attacks
- Session hijacking
- Credential theft
Secure HTTP Headers
We've included the helmet middleware, which sets security-related HTTP headers:
- Content-Security-Policy: Prevents XSS attacks
- X-Content-Type-Options: Prevents MIME-type sniffing
- X-Frame-Options: Prevents clickjacking
- X-XSS-Protection: Provides XSS filtering
Input Validation and Sanitization
We've implemented input validation using express-validator, which helps prevent:
- Injection attacks
- Cross-site scripting (XSS)
- Data corruption
Security Auditing
In a production environment, regularly audit your dependencies for security vulnerabilities using tools like:
- npm audit
- Snyk
- OWASP Dependency-Check
// Run this command regularly to check for vulnerabilities
npm audit
Conclusion
We've successfully built a comprehensive set of API routes for user authentication. Our implementation includes:
- User Signup: Creating new user accounts with secure password storage
- User Login: Authenticating users with JWT tokens
- User Logout: Ending user sessions securely
- Session Management: Retrieving and maintaining user sessions
- Input Validation: Ensuring data quality and security
We've also explored how to extend this foundation with more advanced features like:
- Password reset functionality
- Email verification
- Role-based access control
- API rate limiting
This authentication system follows RESTful principles and modern security best practices, making it a solid foundation for any web application.
Next Steps
From here, you might want to:
- Build a frontend interface to interact with these API routes
- Add more user-related functionality (profile management, account deletion)
- Implement social authentication options (Google, Facebook, etc.)
- Set up automated testing for your authentication routes
- Deploy your API to a production environment with proper security configuration
Remember that authentication is a critical security component of your application, so always keep up with the latest security best practices and regularly update your dependencies to address any discovered vulnerabilities.