Production-Ready Node.js Express OTP/2FA via SMS with Vonage
Two-factor authentication (2FA), often implemented via One-Time Passwords (OTP) sent over SMS, adds a critical layer of security to user accounts. It ensures that users possess their registered phone number in addition to knowing their password.
This guide provides a step-by-step walkthrough for building a robust OTP/2FA SMS verification system using Node.js, Express, and the Vonage Verify API. We'll cover everything from project setup to deployment considerations, aiming for a production-ready implementation.
Technologies Used:
- Node.js: A JavaScript runtime environment.
- Express: A minimal and flexible Node.js web application framework.
- Vonage Verify API: A service specifically designed for handling user verification flows (including SMS OTP). It manages code generation, delivery retries, and code checking.
@vonage/server-sdk
: The official Vonage Node.js SDK for interacting with Vonage APIs.dotenv
: Module to load environment variables from a.env
file.express-session
: Middleware for managing user sessions (to temporarily store verification request IDs).ejs
: A simple templating engine for rendering HTML views.express-rate-limit
: Middleware for basic rate limiting to prevent abuse.
System Architecture:
The flow is straightforward:
- Client (Browser): User enters their phone number into a form.
- Node.js/Express Server:
- Receives the phone number.
- Calls the Vonage Verify API (
verify.start
) to initiate verification. - Stores the returned
request_id
in the user's session. - Renders a verification code entry form.
- Vonage: Sends an SMS containing the OTP code to the user's phone number.
- Client (Browser): User enters the received OTP code into the verification form.
- Node.js/Express Server:
- Receives the OTP code and retrieves the
request_id
from the session. - Calls the Vonage Verify API (
verify.check
) with the code andrequest_id
. - Based on Vonage's response, renders a success or failure page.
- Receives the OTP code and retrieves the
Prerequisites:
- Node.js and npm (or yarn) installed.
- A Vonage API account (Sign up here for free credit).
Final Outcome:
By the end of this guide, you'll have a functional Node.js/Express application capable of:
- Requesting SMS OTP verification for a given phone number.
- Checking the submitted OTP code against Vonage.
- Displaying success or failure messages.
- Implementing basic security measures like rate limiting.
1. Setting Up the Project
Let's initialize our Node.js project and install the necessary dependencies.
1. Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
mkdir vonage-otp-express
cd vonage-otp-express
2. Initialize npm:
Create a package.json
file.
npm init -y
3. Install Dependencies:
We need Express for the web server, the Vonage SDK, dotenv
for environment variables, express-session
for storing the verification state, ejs
for simple HTML templates, and express-rate-limit
for security.
npm install express @vonage/server-sdk dotenv express-session ejs express-rate-limit
4. Project Structure:
Create the following directory structure:
vonage-otp-express/
├── views/ # EJS templates
│ ├── index.ejs
│ ├── verify.ejs
│ ├── success.ejs
│ └── error.ejs
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Files/folders to ignore in git
├── server.js # Main application logic
├── package.json
└── package-lock.json
5. Create .gitignore
:
Create a .gitignore
file in the root directory to prevent sensitive information and unnecessary files from being committed to version control.
# .gitignore
node_modules/
.env
npm-debug.log
*.log
private.key # If storing locally (ensure secure handling)
6. Configure Environment Variables:
Create a .env
file in the root directory. This file will hold your Vonage credentials and other configuration. Never commit this file to Git.
# .env
# Vonage API Credentials (Get from Vonage Dashboard -> API Settings)
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
# Vonage Application ID & Private Key (Recommended for Server SDK)
# (Create an Application in Vonage Dashboard -> Applications)
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
# Vonage Verify API Configuration
VONAGE_BRAND_NAME=MyAppName # Brand name shown in SMS message (No quotes needed unless part of the name)
# Express Session Configuration
SESSION_SECRET=your-very-strong-random-session-secret # Change this!
Obtaining Vonage Credentials:
- API Key & Secret: Log in to your Vonage API Dashboard. Your Key and Secret are displayed at the top. Add them to
.env
. - Application ID & Private Key (Recommended):
- Navigate to "Applications" in the Vonage Dashboard.
- Click "Create a new application".
- Give it a name (e.g., "Node Express OTP App").
- Click "Generate public and private key". A
private.key
file will be downloaded – save this securely in your project root directory (or specify the correct path in.env
). Ensure this file is gitignored and handled securely in deployment. - Enable the "Verify" capability (you might need to toggle other capabilities off if not needed).
- Click "Generate new application".
- Copy the generated "Application ID" and add it to your
.env
file. - Ensure
VONAGE_PRIVATE_KEY_PATH
in.env
points to the location where you savedprivate.key
.
- Brand Name: Set
VONAGE_BRAND_NAME
to the name users should see in the SMS (e.g.,YourApp Verification
). - Session Secret: Replace
your-very-strong-random-session-secret
with a long, random, unpredictable string. This is crucial for session security.
2. Implementing Core Functionality
Now let's write the Express server code to handle the OTP flow.
1. Basic Server Setup (server.js
):
Create server.js
and set up the initial Express app, load environment variables, configure session management, initialize the Vonage SDK, and set up rate limiters.
// server.js
require('dotenv').config(); // Load .env variables first
const express = require('express');
const session = require('express-session');
const path = require('path');
const { Vonage } = require('@vonage/server-sdk');
const rateLimit = require('express-rate-limit');
// --- Basic Express Setup ---
const app = express();
const port = process.env.PORT || 3000;
// --- Middleware ---
// Parse URL-encoded bodies (as sent by HTML forms)
app.use(express.urlencoded({ extended: true }));
// Parse JSON bodies (as sent by API clients)
app.use(express.json());
// Serve static files (if any, e.g., CSS)
// app.use(express.static(path.join(__dirname, 'public')));
// --- View Engine Setup (EJS) ---
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// --- Session Configuration ---
if (!process.env.SESSION_SECRET) {
console.error('[FATAL ERROR] SESSION_SECRET is not defined in .env');
process.exit(1);
}
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: true, // Set to false if you want to control session creation
cookie: {
// secure: process.env.NODE_ENV === 'production', // Use true in production (requires HTTPS)
httpOnly: true, // Default, prevents client-side JS access
maxAge: 300000 // Session expiry in milliseconds (e.g., 5 minutes)
},
// store: // Configure a persistent store for production (see Section 6)
}));
// --- Production Warning for Session Store ---
// The default `express-session` `MemoryStore` is **not suitable for production environments**.
// It leaks memory over time and will lose all session data if the server restarts.
// For production, you **must** configure a persistent session store like Redis (`connect-redis`),
// MongoDB (`connect-mongo`), or a database-backed store. See Section 6 for more details and examples.
if (process.env.NODE_ENV !== 'production' && !app.get('sessionStoreWarningShown')) {
console.warn('[WARN] Using default MemoryStore for sessions. This is not suitable for production! Configure a persistent store (e.g., Redis, Mongo) in production.');
app.set('sessionStoreWarningShown', true); // Show only once
}
// --- Vonage SDK Initialization ---
let vonage;
if (process.env.VONAGE_APPLICATION_ID && process.env.VONAGE_PRIVATE_KEY_PATH) {
try {
vonage = new Vonage({
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: process.env.VONAGE_PRIVATE_KEY_PATH
});
console.log('[INFO] Vonage SDK initialized with Application ID and Private Key.');
} catch (error) {
console.error('[ERROR] Error initializing Vonage SDK with Private Key:', error);
process.exit(1);
}
} else if (process.env.VONAGE_API_KEY && process.env.VONAGE_API_SECRET) {
vonage = new Vonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET
});
console.warn('[WARN] Vonage SDK initialized with API Key/Secret. Consider using Application ID/Private Key for server applications.');
} else {
console.error('[FATAL ERROR] Vonage credentials (Application ID/Private Key or API Key/Secret) not found in .env');
process.exit(1);
}
// --- Rate Limiters (Define before routes) ---
const requestVerificationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: 'Too many verification requests from this IP, please try again after 15 minutes',
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip // Limit by IP
});
const checkVerificationLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 10, // Limit each IP+Session to 10 attempts per windowMs
message: 'Too many code verification attempts, please try again after 5 minutes',
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip + (req.sessionID || '') // Limit by IP and Session ID
});
// --- Routes (Defined below) ---
// --- Start Server ---
app.listen(port, () => {
console.log(`[INFO] Server running on http://localhost:${port}`);
});
// Export app for testing (optional)
module.exports = app;
Explanation:
- We load environment variables using
dotenv
. - Standard Express setup: initializing the app, setting the port.
- Middleware:
express.urlencoded
parses form data,express.json
parses JSON request bodies. Rate limiters are defined. - View Engine: Configures EJS to render files from the
views
directory. - Session: Sets up
express-session
. Thesecret
is loaded from.env
,httpOnly
is set for security, and amaxAge
is set for the session cookie.secure: true
should be enabled in production (requires HTTPS). - Session Store Warning: A prominent warning highlights that
MemoryStore
is not for production. - Vonage SDK: Initializes the SDK, preferring the more secure Application ID/Private Key method. Includes error handling and fallbacks.
- Rate Limiters:
express-rate-limit
instances are configured for both requesting and checking verification to prevent abuse.
2. Create EJS Views:
Create the basic HTML structure for our pages in the views
directory.
-
views/index.ejs
(Phone number input)<!DOCTYPE html> <html> <head> <title>Request OTP</title> <style> /* Basic Styling */ body { font-family: sans-serif; padding: 20px; } label, input { display: block; margin-bottom: 10px; } button { padding: 10px 15px; cursor: pointer; } .error { color: red; margin-top: 10px; } </style> </head> <body> <h1>Enter Phone Number for OTP</h1> <% if (typeof error !== 'undefined' && error) { %> <p class=""error""><%= error %></p> <% } %> <form method=""POST"" action=""/request-verification""> <label for=""phoneNumber"">Phone Number (E.164 format, e.g., +14155552671):</label> <input type=""tel"" id=""phoneNumber"" name=""phoneNumber"" required pattern=""\+[1-9]\d{1_14}""> <button type=""submit"">Send OTP</button> </form> </body> </html>
-
views/verify.ejs
(OTP code input)<!DOCTYPE html> <html> <head> <title>Verify OTP</title> <style> /* Basic Styling */ body { font-family: sans-serif; padding: 20px; } label, input { display: block; margin-bottom: 10px; } button { padding: 10px 15px; cursor: pointer; } .error { color: red; margin-top: 10px; } .info { margin-bottom: 15px; } </style> </head> <body> <h1>Enter OTP Code</h1> <p class=""info"">An OTP code has been sent to your phone. It might take a moment to arrive.</p> <% if (typeof error !== 'undefined' && error) { %> <p class=""error""><%= error %></p> <% } %> <form method=""POST"" action=""/check-verification""> <label for=""code"">OTP Code:</label> <input type=""text"" id=""code"" name=""code"" required pattern=""\d{4_10}""> <!-- Adjust pattern based on expected code length --> <button type=""submit"">Verify Code</button> </form> <!-- Optional: Add a cancel/resend button here --> </body> </html>
-
views/success.ejs
(Success message)<!DOCTYPE html> <html> <head> <title>Verification Successful</title> <style> body { font-family: sans-serif; padding: 20px; color: green; } </style> </head> <body> <h1>Success!</h1> <p>Your phone number has been successfully verified.</p> <p><a href="" />Start Over</a></p> </body> </html>
-
views/error.ejs
(Generic error message)<!DOCTYPE html> <html> <head> <title>Verification Error</title> <style> body { font-family: sans-serif; padding: 20px; color: red; } </style> </head> <body> <h1>Error</h1> <p>An error occurred during verification:</p> <p><strong><%= message %></strong></p> <p><a href="" />Try Again</a></p> </body> </html>
3. Implement Routes (server.js
):
Add the following route handlers to server.js
before the app.listen
call but after the middleware/setup.
// server.js (add these routes after middleware/setup, before app.listen)
// --- Routes ---
// GET: Display the initial phone number entry form
app.get('/', (req, res) => {
// Clear any previous verification request ID from session if user navigates back
if (req.session.requestId) {
// Optional: Consider calling vonage.verify.cancel() here if needed
delete req.session.requestId;
console.log('[INFO] Cleared previous request ID from session.');
}
res.render('index');
});
// POST: Request OTP from Vonage (Apply Rate Limiter)
app.post('/request-verification', requestVerificationLimiter, async (req, res) => {
const phoneNumber = req.body.phoneNumber;
const brand = process.env.VONAGE_BRAND_NAME || 'MyApp'; // Use brand from .env
// Basic input validation
if (!phoneNumber || !/^\+[1-9]\d{1,14}$/.test(phoneNumber)) {
return res.render('index', { error: 'Invalid phone number format. Use E.164 (e.g., +14155552671).' });
}
console.log(`[INFO] Requesting verification for ${phoneNumber}`);
try {
const resp = await vonage.verify.start({
number: phoneNumber,
brand: brand
// You can add other options like:
// code_length: '6', // Default is 4
// pin_expiry: 120, // Expiry time in seconds (default 300)
// workflow_id: 5 // SMS -> TTS -> TTS (default is SMS -> TTS -> Call)
});
if (resp.status === '0') {
// Store request_id in session - IMPORTANT
req.session.requestId = resp.request_id;
console.log(`[INFO] Verification request sent. Request ID: ${resp.request_id}`);
res.render('verify'); // Show the OTP entry form
} else {
// Handle Vonage API errors reported in the response status
console.error(`[ERROR] Vonage verification request failed: Status ${resp.status}, Error: ${resp.error_text}`);
res.render('index', { error: `Verification failed: ${resp.error_text}` });
}
} catch (error) {
// Handle network errors or SDK exceptions
console.error('[ERROR] Error requesting Vonage verification:', error);
res.status(500).render('error', { message: 'Failed to start verification process. Please try again later.' });
}
});
// POST: Check the submitted OTP code with Vonage (Apply Rate Limiter)
app.post('/check-verification', checkVerificationLimiter, async (req, res) => {
const code = req.body.code;
const requestId = req.session.requestId; // Retrieve request_id from session
// Check if we have a request ID in the session
if (!requestId) {
console.error('[ERROR] No request ID found in session. Verification cannot proceed.');
return res.render('error', { message: 'Verification session expired or invalid. Please start over.' });
}
// Basic input validation
if (!code || !/^\d{4,10}$/.test(code)) {
return res.render('verify', { error: 'Invalid code format.' });
}
console.log(`[INFO] Checking code ${code} for request ID ${requestId}`);
try {
const resp = await vonage.verify.check(requestId, code);
if (resp.status === '0') {
// Verification successful!
console.log(`[INFO] Verification successful for request ID: ${requestId}. Event ID: ${resp.event_id}`);
// IMPORTANT: Clear the request ID from the session after successful verification
delete req.session.requestId;
// Optional: Regenerate session ID after successful auth action
// req.session.regenerate((err) => { /* handle error */ res.render('success'); });
res.render('success');
} else {
// Verification failed (wrong code, expired, etc.)
console.warn(`[WARN] Verification check failed: Status ${resp.status}, Error: ${resp.error_text}`);
let displayError = `Verification failed: ${resp.error_text}`;
// Status '16' means wrong code was provided. Allow retry.
// Status '6' indicates the request is inactive (expired, completed, cancelled) - user must start over.
if (resp.status === '6') {
delete req.session.requestId; // Clear inactive request
displayError = 'Verification request is inactive (e.g., expired or already completed). Please start over.';
// Optionally redirect instead of rendering verify: return res.redirect('/');
}
// For other errors (including '16' - wrong code), render 'verify' again
// Do not clear requestId for '16' to allow retries within the rate limit window.
res.render('verify', { error: displayError });
}
} catch (error) {
// Handle network errors or SDK exceptions
console.error('[ERROR] Error checking Vonage verification:', error);
res.status(500).render('error', { message: 'Failed to check verification code. Please try again later.' });
}
});
// Add a generic error handler (Must be LAST middleware)
app.use((err, req, res, next) => {
console.error('[ERROR] Unhandled application error:', err.stack);
res.status(500).render('error', { message: 'An unexpected server error occurred.' });
});
Explanation:
/
(GET): Renders the initial form (index.ejs
). Clears any lingeringrequestId
from the session./request-verification
(POST):- Applies the
requestVerificationLimiter
. - Retrieves and validates the phone number.
- Calls
vonage.verify.start()
. - Stores
requestId
in the session on success (resp.status === '0'
). - Renders
verify.ejs
orindex.ejs
with an error. - Includes
try...catch
.
- Applies the
/check-verification
(POST):- Applies the
checkVerificationLimiter
. - Retrieves the code and the
requestId
from the session. - Calls
vonage.verify.check()
. - On success (
resp.status === '0'
), renderssuccess.ejs
and clears therequestId
from the session. - On failure:
- If status is '6' (inactive), clears the session
requestId
and shows a specific error telling the user to start over. - If status is '16' (wrong code) or other non-zero status, shows the error from Vonage on the
verify.ejs
page, allowing retries (within rate limits).
- If status is '6' (inactive), clears the session
- Includes
try...catch
.
- Applies the
- Error Handler: A final Express error handler catches unhandled errors.
3. Building a Complete API Layer (Testing Endpoints)
While this example primarily renders HTML, the POST routes (/request-verification
, /check-verification
) act as API endpoints receiving form data. You can test them using tools like curl
or Postman.
Testing with curl
:
-
Start your server:
node server.js
-
Request Verification: (Replace
+14155552671
with a test number)# Note: Use -c to save cookies, -b to send cookies curl -X POST http://localhost:3000/request-verification \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'phoneNumber=+14155552671' \ -c cookies.txt -b cookies.txt -v
-H 'Content-Type...'
: Specifies the data format.-d '...'
: Sends the form data.-c cookies.txt -b cookies.txt
: Stores and sends cookies to maintain the session between requests.-v
: Verbose output, shows headers and response.- You should receive the HTML for the
verify.ejs
page and see logs on your server, including therequest_id
.
-
Check Verification: (Wait for the SMS, then replace
1234
with the actual code)curl -X POST http://localhost:3000/check-verification \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'code=1234' \ -c cookies.txt -b cookies.txt -v
- You should receive the HTML for
success.ejs
(on success) orverify.ejs
(on failure) and see logs on your server.
- You should receive the HTML for
Note: If building a pure API (e.g., for a Single Page Application), you would typically:
- Accept JSON request bodies (
Content-Type: application/json
). - Respond with JSON instead of rendering HTML (e.g.,
res.json({ success: true })
orres.status(400).json({ error: 'Invalid code' })
). - Handle session/authentication using tokens (like JWT) or ensure cookies are handled correctly by the client (e.g., with
credentials: 'include'
infetch
).
4. Integrating with Third-Party Services (Vonage Details)
We've already integrated the core Vonage functionality. Key points for this integration:
- Configuration: All necessary configuration (API keys, App ID, private key path, brand name) is handled via the
.env
file. - Secure Key Handling: Using
dotenv
and.gitignore
prevents accidental exposure of credentials in source control. In production, use your deployment platform's secure environment variable management. Ensure theprivate.key
file itself is also handled securely. - SDK Initialization: The
@vonage/server-sdk
is initialized securely, preferring App ID/Private Key. - Core API Calls:
vonage.verify.start(options)
: Initiates the verification. Key options:number
,brand
,code_length
,pin_expiry
.vonage.verify.check(request_id, code)
: Checks the provided code against the ongoing verification request.
- Fallback Mechanisms: The SDK handles some level of network retries internally. Your application code uses
try...catch
to handle SDK or API errors gracefully, informing the user rather than crashing. For critical systems, consider application-level retries with exponential backoff for transient network issues when callingverify.start
. Retryingverify.check
for network errors might be useful, but not for 'wrong code' errors.
5. Error Handling, Logging, and Retry Mechanisms
- Consistent Error Strategy:
- Use
try...catch
blocks around all external API calls (Vonage). - Check the
resp.status
code returned by Vonage methods (0
usually means success). - Use
resp.error_text
to provide specific feedback to the user when Vonage reports an error. - Render distinct error views (
error.ejs
) or display error messages on the relevant form (index.ejs
,verify.ejs
), guiding the user on next steps (e.g., "try again", "start over"). - Use a final Express error handler middleware for unexpected errors.
- Use
- Logging:
- Use structured logging prefixes like
[INFO]
,[WARN]
,[ERROR]
for clarity. - Log key events: verification start/success/failure, errors, rate limit hits.
- Include relevant context like
requestId
in logs where applicable. - Production Logging: Replace
console.*
with a dedicated logging library likewinston
orpino
. Configure log levels (INFO, WARN, ERROR), formats (JSON is good for machine parsing), and transport (write to files, send to logging services like Datadog, Loggly, ELK stack, etc.).
// Example using console (as implemented in Section 2) console.log(`[INFO] Requesting verification for ${phoneNumber}`); console.warn(`[WARN] Verification check failed for ${requestId}: Status ${resp.status}, Error: ${resp.error_text}`); console.error(`[ERROR] Error requesting Vonage verification: ${error.message}`, error);
- Use structured logging prefixes like
- Retry Mechanisms:
- Vonage Internal: The Verify API itself handles retries for delivering the SMS/call according to the chosen workflow (e.g., SMS -> Wait -> Call).
- Application Level: Implement rate limiting (as done) to prevent abuse from user retries. For calls to Vonage (
verify.start
,verify.check
), consider retries only for transient network errors (e.g., timeouts, 5xx errors from Vonage). Use libraries likeasync-retry
for exponential backoff. Avoid retrying application logic errors (like invalid input) or specific Vonage errors like 'wrong code'.
6. Database Schema and Data Layer
For this specific standalone OTP example, we rely on express-session
to temporarily store the requestId
.
- No Persistent Database Needed (for this demo): We don't need to store user accounts or link the verification permanently in this basic guide.
- Production Session Store: As highlighted multiple times, the default
MemoryStore
forexpress-session
is unsuitable for production. You must configure a persistent store:- Redis:
connect-redis
(Recommended for performance) - MongoDB:
connect-mongo
- PostgreSQL/MySQL:
connect-pg-simple
,express-mysql-session
- Example setup (conceptual for Redis, place before
app.use(session(...))
):// npm install redis connect-redis const redis = require('redis'); const RedisStore = require('connect-redis')(session); // Configure Redis client connection (use env vars for URL/credentials) const redisClient = redis.createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' }); redisClient.connect().catch(err => { console.error('[ERROR] Could not connect to Redis:', err); process.exit(1); // Fail fast if session store is unavailable }); redisClient.on('error', err => console.error('[ERROR] Redis Client Error:', err)); // Replace the default MemoryStore const sessionStore = new RedisStore({ client: redisClient }); // Update app.use(session(...)) to include the store: app.use(session({ store: sessionStore, // Use Redis store secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, // Recommended: only save sessions that are modified cookie: { secure: process.env.NODE_ENV === 'production', // Requires HTTPS httpOnly: true, maxAge: 300000 // 5 minutes } }));
- Redis:
- Integrating with Existing User Models: If adding 2FA to an existing application with a user database:
- You wouldn't typically store the temporary
requestId
directly in the user table. - After successful verification (
status === '0'
in/check-verification
), you would update the user's record in your database to mark their phone number as verified or update their 2FA status. This requires associating the session with a logged-in user ID. - Schema Example (Conceptual - User Table):
Upon successful verification for a logged-in user, you'd run an SQL UPDATE:
CREATE TABLE users ( id SERIAL PRIMARY KEY, username VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, phone_number VARCHAR(20) UNIQUE, is_phone_verified BOOLEAN DEFAULT FALSE, -- other fields... created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() );
UPDATE users SET is_phone_verified = TRUE, updated_at = NOW() WHERE id = <user_id>;
- You wouldn't typically store the temporary
7. Adding Security Features
Security is paramount for authentication flows.
- Input Validation:
- Phone Number: Use strict validation (E.164 format regex:
^\+[1-9]\d{1,14}$
). Done in Section 2. Consider libraries likelibphonenumber-js
for more advanced validation if needed (e.g., checking region validity), but ensure it's kept updated. - OTP Code: Validate the format (e.g., 4-10 digits:
^\d{4,10}$
). Done in Section 2. Ensure the pattern matchescode_length
if customized. - Sanitization: EJS escapes HTML by default with
<%= %>
. Use parameterized queries or ORM methods if interacting with a database to prevent SQL injection.
- Phone Number: Use strict validation (E.164 format regex:
- Rate Limiting: Essential to prevent abuse (SMS flooding, code guessing). Implemented in Section 2 using
express-rate-limit
for both/request-verification
and/check-verification
. Tune the limits (max
,windowMs
) based on expected usage patterns and risk tolerance. - Brute Force Protection: Rate limiting is the primary defense. Ensure limits are reasonably strict. Vonage Verify API also has its own internal checks.
- Secure Session Management:
- Use a strong, unpredictable
SESSION_SECRET
stored securely (env var). - Use
secure: true
for cookies in production (requires HTTPS). - Use
httpOnly: true
for cookies (prevents client-side script access). - Set an appropriate
maxAge
for session cookies. - Use a persistent, secure session store (e.g., Redis) for production.
- Consider regenerating the session ID after successful verification using
req.session.regenerate()
.
- Use a strong, unpredictable
- HTTPS: Always use HTTPS in production to encrypt traffic between the client and server, protecting session cookies and sensitive data. Use a reverse proxy like Nginx or Caddy, or your hosting platform's features to handle TLS termination.
- Environment Variables: Store all secrets (API keys, session secret, database credentials) in environment variables, not hardcoded in the source code. Use
.env
for local development and your deployment platform's secure configuration management for production. - Dependency Management: Keep dependencies updated (
npm audit
,yarn audit
) to patch known vulnerabilities. Use tools like Snyk or Dependabot. - Security Headers: Use middleware like
helmet
to set various HTTP headers that improve security (e.g.,X-Frame-Options
,Strict-Transport-Security
,Content-Security-Policy
).npm install helmet
// server.js (add near the top, after express() but before routes) const helmet = require('helmet'); app.use(helmet()); // Configure helmet further if needed, e.g., for Content Security Policy