Hi! In this tutorial we’re going to go over setting up an authentication system that relies on email + password. We’ll be using React on the frontend and Node.js on the backend.

The workflows we will support will be:

Sign up:

  1. User submits the credentials they want to use to sign in
  2. The user verifies their email
  3. They may now log in

Log in:

  1. User submits their email/password
  2. If the user exists, a session is created and the user is brought to a page that displays their basic info
  3. The user can refresh the page and they will still be logged in.

Log out:

  1. The user can request to log out, canceling out their session

Let’s set up our directories

Run the following:

mkdir email_auth_tutorial && cd email_auth_tutorial

We are going to need to create a directory for the frontend code and a directory for the backend code. Let’s start with the frontend code. Run:

npx create-react-app email_auth_tutorial_frontend --template typescript

As you can tell, we will be working with typescript in this example. If you are not too familiar with Typescript, don’t worry, we’ll briefly touch on it too. I enjoy using Typescript because when I make data type mistakes, the compiler alerts me about it with enough info to help me quickly correct the issue.

Let’s add the backend directory, run

mkdir email_auth_tutorial_backend

Frontend

We are now going to add the user interfaces on the frontend.

Go to the frontend directory and start the server:

cd email_auth_tutorial_frontend && npm start

FYI, I’m using VSCode, and my set up looks like this:

The only package dependency we’re missing is react-router-dom. We need that for routing. In a separate terminal, run:

npm i --save react-router-dom

The first thing we need to do is integrate the react router system into our app. Update the index.tsx file so it looks like the following:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

As you can tell, we haven’t added any new paths yet. We’re going to need it in a bit though for our sign up and log in pages.

Let’s now update App.tsx to look like the following:

import React from 'react';
import './App.css';
import { Link } from 'react-router-dom';
function App() {
return (
<div>
<div>
<div>
<Link to="/login">Log in</Link>
</div>
<div>
<Link to="/signup">Sign up</Link>
</div>
<div>
You are not logged in.
</div>
</div>
</div>
);
}
export default App;

In this tutorial we are not concerning ourselves with styles. As you can see, we now have a link to go to a log in and sign up page. We are also displaying a placeholder stating that we are not logged in.

If you click on either of those links, it’ll render a blank page. That’s because we haven’t set up those routes yet. In the src directory, create four new files:

  • Login.tsx
  • Signup.tsx
  • CheckYourEmail.tsx
  • VerifyEmail.tsx

Add the following text to Login.tsx:

import React from 'react';function Login() {
return (
<div>
log in
</div>
);
}
export default Login;

and the following to Signup.tsx:

import React from 'react';function Signup() {
return (
<div>
sign up
</div>
);
}
export default Signup;
  • CheckYourEmail.tsx:
import React from 'react';function CheckYourEmail() {
return (
<div>
Please check your email for a verification link.
</div>
);
}
export default CheckYourEmail;

VerifyEmail.tsx:

import React from 'react';function VerifyEmail() {
return (
<div>
Verifying...
</div>
);
}
export default VerifyEmail;

We add the minimal amount of code just to verify that the pages work. Back in index.tsx, add the appropriate routes. It should now look like:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Login from './Login';
import Signup from './Signup';
import CheckYourEmail from './CheckYourEmail';
import VerifyEmail from './VerifyEmail';
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/check-your-email" element={<CheckYourEmail />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Verify that these paths render something:

Sign up interface:

Our sign up page needs the following qualities:

  • Ability to route to the home or log in page
  • Ability to sign up via name, email, password
  • Ability to validate the user’s inputs
  • A button that submits the user’s inputs to the backend

I went ahead and scaffolded out the basic page:

import React from "react";
import { Link, useNavigate } from "react-router-dom";
function SignUp() {const navigate = useNavigate();const [formState, setFormState] = React.useState({
username: "",
email: "",
password: ""
})
const onChange = (e: React.ChangeEvent<HTMLInputElement>, type: string) => {
e.preventDefault();
setFormState({
...formState,
[type]: e.target.value
})
}
const getErrors = (): string[] => {
const errors = [];
if (!formState.username) errors.push("Name required");
if (!formState.email) {
errors.push("Email required");
} else if (!/^\S+@\S+\.\S+$/.test(formState.email)) {
errors.push("Invalid email");
}
if (!formState.password) errors.push("Password required");
return errors;
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const errors = getErrors();
for (let error of errors) {
alert(error);
}
if (errors.length) return;
const response = await fetch('http://localhost:8000/api/v1/signup', {
method: "post",
headers: {
// needed so express parser says the body is OK to read
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: formState.username,
email: formState.email,
password: formState.password
})
})
if (response.status !== 200) {
// TODO: Add more detailed error handling.
return alert("Something went wrong.");
}
navigate("/check-your-email");
}
return (
<div>
<h1>Sign up</h1>
<form onSubmit={onSubmit}>
<div>
<label>Name</label>
<input onChange={(e) => onChange(e, "username")} type="text" value={formState.username} />
</div>
<div>
<label>Email</label>
<input onChange={(e) => onChange(e, "email")} type="email" value={formState.email} />
</div>
<div>
<label>Password</label>
<input onChange={(e) => onChange(e, "password")} type="password" value={formState.password} />
</div>
<button type="submit">Submit</button>
</form>
<div>
<Link to="/login">Already have an account? Log in</Link>
</div>
<div>
<Link to="/">Back to home</Link>
</div>
</div>
);
}
export default SignUp;

I highly recommend adding more detailed error handling if you expand beyond this app.

Add that to your Signup.tsx file and play around with it in the browser. Also go through the code. It’s all fairly boilerplate. The most interesting code is in the onSubmit function. As you can see, we are making a function call to an endpoint that does not exist (yet).

Next, let’s update our Login.tsx file:

import React from "react";
import { Link, useNavigate } from "react-router-dom";
function Login() {const navigate = useNavigate();const [formState, setFormState] = React.useState({
email: "",
password: ""
})

const onChange = (e: React.ChangeEvent<HTMLInputElement>, type: string) => {
e.preventDefault();
setFormState({
...formState,
[type]: e.target.value
})
}
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const response = await fetch('http://localhost:8000/api/v1/login', {
method: "post",
credentials: "include",
headers: {
// needed so express parser says OK to read
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: formState.email,
email: formState.email,
password: formState.password
})
})
if (response.status !== 200) {
return alert("Something went wrong");
}
navigate("/");
}
return (
<div>
<h1>Log in</h1>
<form onSubmit={onSubmit}>
<div>
<label>Email</label>
<input onChange={(e) => onChange(e, "email")} type="email" value={formState.email} />
</div>
<div>
<label>Password</label>
<input onChange={(e) => onChange(e, "password")} type="password" value={formState.password} />
</div>
<button>Submit</button>
</form>
<div>
<Link to="/signup">Don't have an account? Sign up</Link>
</div>
<div>
<Link to="/">Back to home</Link>
</div>
</div>
);
}
export default Login;

Once again, fairly boiler plate. In the onSubmit function, we are also passing along credentials: “include”. This is going to be useful for us to create authenticated sessions. Basically, we will be retrieving a cookie from that api/v1/login endpoint and storing that in the browser. This parameter ensures we get it and don’t ignore it when the backend sends it over.

Update VerifyEmail.tsx like so

import React from "react";
import { Link } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
enum VerificationState {
pending = "pending",
invalid = "invalid",
already_verified = "already_verified",
success = "success"
}
function VerifyEmail() {const [searchParams] = useSearchParams();const [verificationState, setVerificationState] = React.useState(VerificationState.pending);const attemptToVerify = async () => {
const code = searchParams.get("code");
const email = searchParams.get("email");
if (!code || !email) {
return setVerificationState(VerificationState.invalid)
}
const response = await fetch('http://localhost:8000/api/v1/verify-email', {
method: "post",
headers: {
// needed so express parser says OK to read
'Content-Type': 'application/json'
},
body: JSON.stringify({
code,
email
})
})
const result = await response.json();
if (result.data === "already verified") {
return setVerificationState(VerificationState.already_verified)
}
setVerificationState(VerificationState.success);
}

React.useEffect(() => {
attemptToVerify()
}, []);
switch (verificationState) {
case VerificationState.success: {
return (
<div>
Email verified. Please <Link to="/login">sign in</Link>.
</div>
)
}
case VerificationState.already_verified: {
return (
<div>
This link is expired. Please <Link to="/login">log in</Link>.
</div>
)
}
case VerificationState.invalid: {
return (
<div>
This link is invalid link.
</div>
)
}
default: {
return (
<div>
Verifying...
</div>
);
}
}
}export default VerifyEmail;

Update App.tsx to the following:

import React from "react";
import { Link } from "react-router-dom";
import './App.css';
function App() {const [user, setUser] = React.useState(null);React.useEffect(() => {
fetch('http://localhost:8000/api/v1/user', {
method: "get",
credentials: "include",
headers: {
// needed so express parser says OK to read
'Content-Type': 'application/json'
},
})
.then(response => response.json())
.then(result => {
console.log(result, 'result')
if (result.data) {
setUser(result.data)
}
});
}, []);
const logout = async () => {
const response = await fetch('http://localhost:8000/api/v1/logout', {
method: "get",
credentials: "include",
headers: {
// needed so express parser says OK to read
'Content-Type': 'application/json'
},
})
const result = await response.json();
if (result.data) {
setUser(null)
}
}
return (
<div className="App">
{
!user && (
<div>
<div>
<Link to="/login">Log in</Link>
</div>
<div>
<Link to="/signup">Sign up</Link>
</div>
<p>Not logged in</p>
</div>
)
}
{
user && <div>{user}<button onClick={logout}>logout</button></div>
}
</div>
);
}
export default App;

Back end

Let’s start to flesh out our back end. In a separate terminal, navigate such that your present working directory is email_auth_tutorial_backend. Run

npm init -y

The -y flag is just to quickly accept all the questions that get asked. Create a new file index.js:

touch index.js

What we want to do at this moment is to just get a basic server running. Run the following:

npm i --save express

Add the following to index.js

const express = require('express');
const app = express();
const port = 8000;app.listen(port, () => console.log('App listening on port ' + port));

After than, run:

node index.js

If you see the text “App listening on port 8000” then all is well. Disconnect from the server by holding down: CTRL + C

Note: We are not going to add anything to listen for changes to our server. Every time you want to see a change, just disconnect from the server and start it up again, ie. hold CTRL + C followed by running node index.js in the terminal.

Routes

Just as we had routes on the frontend, we now need some backend routes. These will be the endpoints that we hit from the frontend. Let’s get the basic infrastructure set up. Create a new file routes.js from the root directory, and add the following to it:

var express = require('express');
var router = express.Router();
router.route('/ping')
.get((req, res) => {
res.status(200).send({
data: "pong"
})
});
module.exports = router;

As you can see, when we hit the endpoint /ping, it’ll return “pong”. This is just to verify that the system is generally working.

Update index.js so that it uses the routes.js file. It should look like so:

const express = require('express');
const routes = require("./routes");
const app = express();app.use('/api/v1/', routes);const port = 8000;app.listen(port, () => console.log('App listening on port ' + port));

Restart the server and navigate in the browser to: http://localhost:8000/api/v1/ping

If you see the following, you’re good to go: {“data”:”pong”}

Database

The next thing we want to do is set up the database. If you don’t have Mongo set up on your machine, follow the instructions to download it here:

Follow the document’s instructions to start up the MongoDB Service.

On MacOS, run the following from your terminal:

brew services start mongodb-community@5.0

I can’t verify what it is on Windows, but I believe you should run the following:

"C:\Program Files\MongoDB\Server\5.0\bin\mongod.exe" --dbpath="c:\data\db"

If that was successful, you can now run the following from the terminal:

mongo

This will put you inside the Mongo Shell. From here you can explore the data in your database. From within the shell, run the following just to start a new datastore:

use SampleDatabase;

In a terminal at the root of the email_auth_tutorial_backend folder, run:

npm i --save body-parser cors express-session mongoose nodemailer passport passport-local passport-local-mongoose passport-mongoose

The general run down of each of these libraries is:

body-parser: helps with parsing the JSON from the body in incoming requests

cors: helps with managing CORS

express-session: helps with managing user sessions once authenticated

mongoose: helps with managing our MongoDB data

nodemailer: helps with sending emails (email verification in our case)

passport: helps with the details of authentication/authorization

passport-local: helps with details related to email/password authentication

passport-local-mongoose: helps our MongoDB data interact with our email-based authentication system

passport-mongoose: helped our MongoDB data interact with our authentication system

Once that runs successfully, update index.js to look like the following:

/*  EXPRESS SETUP  */const express = require('express');
const app = express();
app.use(express.static(__dirname));const session = require('express-session');
const mongoose = require('mongoose');
const passport = require('passport');
// helps parse the body so the server can understand JSON.
const bodyParser = require('body-parser');
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// sets up session management
const expressSession = session({
secret: 'this_is_a_secret_that_should_not_be_disclosed',
resave: false,
saveUninitialized: false,
});
app.use(expressSession);
// Lots of behind-the-scenes configurations
// happen here to reduce the amount of code
// you have to write in order for express and passport to communicate with each other.
app.use(passport.initialize());
app.use(passport.session());
// cross-origin resource sharing set up
var cors = require('cors');
var corsOption = {
origin: true,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true,
exposedHeaders: ['x-auth-token', "Access-Control-Allow-Credentials"]
};
app.use(cors(corsOption));
const port = 8000;
app.listen(port, () => console.log('App listening on port ' + port));
const routes = require("./routes");// database setup
mongoose.connect("mongodb://localhost/SampleDatabase",
{ useNewUrlParser: true, useUnifiedTopology: true });
app.use('/api/v1/', routes);

The elements we just added were:

  • Body parser: Express by default can’t parse JSON so it won’t understand the data we send to it in JSON format.
  • Passport.js: This is a standard 3rd party library that can handle most of the authentication/authorization logic on our behalf.
  • A database: We need a place to store user data. In this tutorial, we’ll be using Mongo because it’s the easiest one to configure.
  • Session manager: Once a user logs in and they refresh the page, how do we verify that they’re the correct person? In this tutorial, we’re going to use Passport’s default session strategy which is to use cookies. We don’t want to touch the cookies ourselves though, so let’s delegate that to the session manager.
  • Cross-origin resource sharing (CORS): Since the website is located at localhost:3000 and our server is located at localhost:8000, the two pages can’t interact by default. This is a default security measure enacted by your browser. We need to configure our server to accept requests originating from localhost:3000.

Take a moment to go through that code. Take a note of the inline comments to better understand what’s happening. This is probably a good moment to ensure the server is still running by running node index.js

Bind MongoDB with Passport

The next thing we should do is to take measures to connect the data from our datastore in MongoDB to our passport.js system. Create a new file user.js

In this file, we will:

  1. Add the user schema
  2. Bind passport with our user schema
  3. Create an exportable UserDetails model that we can use elsewhere to interact with the user collection in MongoDB.
  4. Configure the email/password authentication strategy
  5. Add a deserializer/serializer for users. A user is serialized when you first log in and the user data is retrieved from the database. A user is deserialized when an authenticated API call is made; we want the deserialized version of the user so we have access to its methods.

Without further ado, add the following to user.js

const mongoose = require('mongoose');
const passportLocalMongoose = require('passport-local-mongoose');
const passport = require('passport');
const PassportLocalStrategy = require('passport-local');
const UserDetail = new mongoose.Schema({
username: String,
email: String,
password: String,
emailVerified: Boolean,
emailVerificationHash: String
});
UserDetail.plugin(passportLocalMongoose);const UserDetails = mongoose.model('user', UserDetail, 'user');passport.use(new PassportLocalStrategy(
function(username, password, done) {
UserDetails.findOne({ email: username }, function (err, user) {
if (err) { return done(err); }
if (!user) { return done(null, false); }
user.authenticate(password, function(err,model,passwordError){
if(passwordError){
console.log(err)
done(null, false);
} else if(model) {
done(null, user);
}
})
});
}
));
passport.serializeUser(function(user, done) {
console.log('serializing user: ');
console.log(user);
done(null, user._id);
});
passport.deserializeUser(function(id, done) {
UserDetails.findById(id, function(err, user) {
done(err, user);
});
});
module.exports = { UserDetails };

You’ve got mail

We need a service that can send mail. Though this is just a basic demo, let’s still go through the process of setting up a place to hold all of our services, run:

mkdir services && touch services/mail.service.js

In mail.service.js, add the following

const nodemailer = require("nodemailer");const sendMail = async ({
subject,
to,
html,
text
}) => {
let transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: '<YOUR_GMAIL_USERNAME<@gmail.com',
pass: '<YOUR_PASSWORD_DONT_SAVE_THIS_TO_GITHUB>' // naturally, replace both with your real credentials or an application-specific password
}
});
return await transporter.sendMail({
from: '"The Demo App Team" <foo@example.com>', // sender address
to, // list of receivers
subject, // Subject line
text, // plain text body
html, // html body
});
}
module.exports = {
sendMail
}

We want a specific mail service so that whenever we need to send mail in the future in our app, we can call this sendMail function.

IMPORTANT: In order for email to work, you’ll need to set Less Secure Apps to on in your gmail account. Find instructions here:

Follow the link where it says: Go to the Less secure app access section of your Google Account. You might need to sign in.

Authentication endpoints

We are now going to add endpoints for the following:

  1. Signup
  2. Login
  3. Logout
  4. Email Verification

Routes.js should now look like so:

var express = require('express');
var router = express.Router();
const { UserDetails } = require("./user");
const { sendMail } = require('./services/mail.service');
const passport = require('passport');
const generateHash = () => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
const sendVerificationEmail = async ({
to,
verificationHash,
email
}) => {
const linkAddress = `https://localhost:3000/verify-email?code=${verificationHash}&email=${email}`
return await sendMail({
to,
subject: "Please verify your email",
text: `Hi John,
\nPlease click on the following link to verify your email:
\n${linkAddress}
\n
\nSincerely,
The Demo App Team`,
html: `Hi John,
<br>Please click on the following link to verify your email:
<br><a href="${linkAddress}">${linkAddress}</a>
<br><br>Sincerely,
<br>The Demo App Team`
});
}
router.route('/ping')
.get((req, res) => {
res.send({
data: "pong"
})
});
router.route('/verify-email')
.post(async (req, res) => {
UserDetails.findOne({
email: req.body.email,
emailVerificationHash: req.body.code
}, (err, account) => {
if (err) {
console.log(err);
return res.status(400).send({ error: err.message });
}
if (account.emailVerified) {
return res.status(200).send({data: "already verified"});
}
UserDetails.updateOne({_id: account.id}, {
$set: {
emailVerified: true
}
}, (_err) => {
if (_err) {
console.log(_err);
return res.status(400).send({ error: _err.message });
}
return res.status(200).send({data: "done"})
})

})
});
function checkAuthentication(req,res,next){
if(req.isAuthenticated()) {
next();
} else{
res.status(403).send({reason: "unauthenticated"});
}
}
router.get('/user', checkAuthentication, (req, res) => {
console.log(req.session, 'sesion', req.user)
res.status(200).send({data: req.user.email})
})
router.route('/logout')
.get((req, res) => {
req.logout()
res.status(200).send({data: "OK"})
})
router.route('/login')
.post((req, res, next) => {
passport.authenticate('local',
(err, user, info) => {
if (err) {
return next(err);
}
if (!user) {
return res.status(400).send({data: "no user"})
}
req.logIn(user, function(err) {
if (err) {
return res.status(400).send({data: "error"})
}
return res.status(200).send({data: "ok"})
});
})(req, res, next);
})
router.route('/signup')
.post((req, res) => {
const emailVerificationHash = generateHash();
UserDetails.register({
username: req.body.username,
email: req.body.email,
emailVerified: false,
emailVerificationHash
}, req.body.password, async (err, account) => {
if (err) {
console.log(err);
return res.status(400).send({ error: err.message });
}
await sendVerificationEmail({
to: req.body.email,
verificationHash: emailVerificationHash,
email: req.body.email
})
res.status(200).send({ data: "ok" });
})
});
module.exports = router;

Unrelated PSA: Looking for a new high paying software development job? Send me your resume to alexleondeveloper@gmail.com and I’ll get back to you!

Restart your server and visit localhost:3000. Try out the workflows:

  • Sign up for an account
  • Check your email for the link and verify your email
  • Log in to your account
  • In the home page, refresh the page and notice your email appears in the page (this is the user info we sent over from the backend)
  • Log out

That’s all for now! Please find the source code here:

--

--

Alexander Leon

I help developers with easy-to-understand technical articles encompassing System Design, Algorithms, Cutting-Edge tech, and misc.