DEV Community

Cover image for Setting Up Gmail OAuth2 With Nodemailer and Google Cloud Platform (GCP)
Tech In Vernacular
Tech In Vernacular

Posted on

Setting Up Gmail OAuth2 With Nodemailer and Google Cloud Platform (GCP)

Sending emails from applications now don evolve significantly from how e take dey be over the years. The normal way wey be username and password authentication for Gmail SMTP wey we been dey use before no really dey market again because of some kind security matters and Google's own restriction for apps wey dey considered "less secure".

The latest way now wey secure pass na to use Gmail OAuth2 authentication, with another external emailing padi like Nodemailer. I been just run am for my portfolio na why e sweet me to write like this, make another person for fit find am helpful.

For this guide, you go sabi how to:

  • Configure a Google Cloud Platform (GCP) project
  • Enable the Gmail API
  • Configure the OAuth Consent Screen
  • Generate OAuth credentials
  • Generate a refresh token
  • Configure Nodemailer with Gmail OAuth2
  • Handle common errors
  • Secure your application properly

This tutorial go work for:

  • Node.js
  • Express
  • Nuxt server routes
  • Vue backend APIs
  • Next.js API routes
  • TypeScript projects

Table of Contents

  1. Why use Gmail OAuth2?
  2. Prerequisites
  3. Creating a Google Cloud Project
  4. Enabling the Gmail API
  5. Configuring the OAuth Consent Screen
  6. Creating OAuth Credentials
  7. Installing Dependencies
  8. Generating a Refresh Token
  9. Configuring Nodemailer
  10. Sending Emails
  11. Escaping HTML and Preventing Injection
  12. Common Errors and Fixes
  13. Production Recommendations
  14. Final Thoughts

Why use Gmail OAuth2?

Google don deprecate the password-based SMTP authentication for many applications.

OAuth2 dey offer:

  • Better security
  • Token-based authentication
  • No exposed Gmail passwords
  • Controlled access scopes
  • Revocable access

Instead make you do something like:

auth: {
  user: "example@gmail.com",
  pass: "gmail-password"
}
Enter fullscreen mode Exit fullscreen mode

You go just do like this:

auth: {
  type: "OAuth2",
  user,
  clientId,
  clientSecret,
  refreshToken
}
Enter fullscreen mode Exit fullscreen mode

Prerequisites

Before you start cook anything, make sure say the following ingredients dey ground:

  • A Google account
  • Node.js installed
  • npm or yarn
  • A backend/server environment
  • Basic knowledge of JavaScript or TypeScript

Creating a Google Cloud Project

Go to the Google Cloud Console:

Google Cloud Console

Step 1: Create new project

  1. Click the project dropdown wey dey the top bar
  2. Click New Project
  3. Enter project name wey you want
  4. Click Create

Wait for the project make e initialize.


Enabling the Gmail API

Once your project don create:

  1. Open the APIs Library:

Google APIs Library

  1. Search for:
Gmail API
Enter fullscreen mode Exit fullscreen mode
  1. Click the Gmail API
  2. Click Enable

If you no enable this Gmail API, the OAuth email sending go just dey chop dust.


Configuring the OAuth Consent Screen

This step na must before you go generate OAuth credentials.

Navigate go:

OAuth Consent Screen

Step 1: Choose user type

Choose:

External
Enter fullscreen mode Exit fullscreen mode

Then click Create.

Step 2: Fill the app information

Provide:

  • App name
  • Support email
  • Developer contact email

Example:

App Name: Portfolio Mailer
Enter fullscreen mode Exit fullscreen mode

Step 3: Add test users

If your app still dey for testing mode:

  1. Open the Test Users section
  2. Add the Gmail account wey you wan dey use for sending the emails

If you jump this step pass, you go dey jam error:

unauthorized_client
Enter fullscreen mode Exit fullscreen mode

Creating OAuth credentials

Now, na to create the OAuth credentials.

Go to:

Google Cloud Credentials

Step 1: Create OAuth client ID

  1. Click Create Credentials
  2. Select OAuth Client ID

Step 2: Select application type

Choose:

Web Application
Enter fullscreen mode Exit fullscreen mode

No go choose:

  • Desktop App
  • Android
  • iOS

Step 3: Add redirect URI

Under the following section:

Authorized redirect URIs
Enter fullscreen mode Exit fullscreen mode

Add:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

or your preferred port. This one na must if we wan generate refresh tokens.

Step 4: Save credentials

After you don done, Google go provide:

Client ID
Client Secret
Enter fullscreen mode Exit fullscreen mode

Save them securely, you fit copy am or you fit download am.


Installing dependencies

Install the packages wey you go need:

npm install nodemailer googleapis dotenv
Enter fullscreen mode Exit fullscreen mode

For TypeScript, also run:

npm install -D tsx typescript
Enter fullscreen mode Exit fullscreen mode

Environment variables

Create a .env file:

SMTP_USER=your-email@gmail.com

GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
GOOGLE_OAUTH_REFRESH_TOKEN=your-refresh-token
Enter fullscreen mode Exit fullscreen mode

No go ever commit .env files go GitHub, dem suppose don sing this one for your ear enough.


Generating a refresh token

This na one of the most important part for this setup.

Step 1: Create a script

Create a script file:

scripts/generate-refresh-token.ts
Enter fullscreen mode Exit fullscreen mode

for the root of your application.

Then add the following code:

import { google } from "googleapis";
import dotenv from "dotenv";

dotenv.config();

const oauth2Client = new google.auth.OAuth2(
    process.env.GOOGLE_CLIENT_ID,
    process.env.GOOGLE_CLIENT_SECRET,
    "http://localhost:3000"
);

const scopes = [
    "https://mail.google.com/",
];

const url = oauth2Client.generateAuthUrl({
    access_type: "offline",
    scope: scopes,
    prompt: "consent",
});

console.log("Authorization url: ", url);
Enter fullscreen mode Exit fullscreen mode

Step 2: Run the script

Using TypeScript, run:

npx tsx scripts/generate-refresh-token.ts
Enter fullscreen mode Exit fullscreen mode

Or JavaScript:

node scripts/generate-refresh-token.js
Enter fullscreen mode Exit fullscreen mode

Step 3: Open the authorization URL

The terminal go vomit the Google authorization URL give you.

Copy the link open am for your browser.

Login with the same Gmail account as:

SMTP_USER
Enter fullscreen mode Exit fullscreen mode

And approve the permissions wey go come up.

Step 4: Copy the authorization code

After the approval, Google go redirect go the url wey you set for step 3 when you dey create the OAuth credentials:

http://localhost:3000/?code=...
Enter fullscreen mode Exit fullscreen mode

Copy the code value.

Example:

4/0AX4XfWh...
Enter fullscreen mode Exit fullscreen mode

Step 5: Exchange code for the refresh token

Update the script with the following:

import { google } from "googleapis";
import dotenv from "dotenv";

dotenv.config();

const oauth2Client = new google.auth.OAuth2(
    process.env.GOOGLE_CLIENT_ID,
    process.env.GOOGLE_CLIENT_SECRET,
    "http://localhost:3000"
);

async function getRefreshToken() {
    const code = "PASTE_AUTHORIZATION_CODE_HERE";

    const { tokens } = await oauth2Client.getToken(code);

    console.log("Get tokens: ", tokens);
}

getRefreshToken();
Enter fullscreen mode Exit fullscreen mode

Step 6: Run the script again

npx tsx scripts/generate-refresh-token.ts
Enter fullscreen mode Exit fullscreen mode

You suppose see something wey go be like this:

{
  "access_token": "...",
  "refresh_token": "...",
  "scope": "https://mail.google.com/",
  "token_type": "Bearer"
}
Enter fullscreen mode Exit fullscreen mode

Copy the refresh token put inside the .env.


Configuring Nodemailer

Now make we configure the transporter.

import nodemailer from "nodemailer";
import { google } from "googleapis";

const clientId = process.env.GOOGLE_CLIENT_ID!;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET!;
const refreshToken = process.env.GOOGLE_OAUTH_REFRESH_TOKEN!;
const user = process.env.SMTP_USER!;

const oauth2Client = new google.auth.OAuth2(
    clientId,
    clientSecret,
    "https://developers.google.com/oauthplayground"
);

oauth2Client.setCredentials({
    refresh_token: refreshToken,
});

async function createTransporter() {
    const accessToken = await oauth2Client.getAccessToken();

    return nodemailer.createTransport({
        service: "gmail",
        auth: {
            type: "OAuth2",
            user,
            clientId,
            clientSecret,
            refreshToken,
            accessToken: accessToken.token || undefined,
        },
    });
}
Enter fullscreen mode Exit fullscreen mode

Sending emails

Example:

const transporter = await createTransporter();

await transporter.sendMail({
    from: `"Portfolio Contact" <${user}>`,
    replyTo: "visitor@example.com",
    to: user,
    subject: "New Portfolio Message",

    text: `
Name: John Doe
Email: visitor@example.com

Message:
Hello there!
`,

    html: `
        <div>
            <h2>New Portfolio Message</h2>

            <p><strong>Name:</strong> John Doe</p>

            <p>
                <strong>Email:</strong>
                visitor@example.com
            </p>

            <p>Hello there!</p>
        </div>
    `,
});
Enter fullscreen mode Exit fullscreen mode

Important: Use replyTo instead of from

This one na one common mistake wey fit happen.

No go do like this:

from: "visitor@example.com"
Enter fullscreen mode Exit fullscreen mode

Gmail fit reject am, e dey get choko sometimes.

Instead, do am like this:

from: `"Portfolio Contact" <${user}>`,
replyTo: visitorEmail,
Enter fullscreen mode Exit fullscreen mode

This one dey sweet Gmail belle, e go still allow make you fit send direct replies give the visitor.


Escaping HTML and preventing injection

No go ever inject raw user input put inside the HTML. This no safe at all and proper wahala fit brew if anyhow person go jam am:

html: `<p>${message}</p>`
Enter fullscreen mode Exit fullscreen mode

Create an escapeHtml helper function, this way dey safe enough:

function escapeHtml(unsafe: string) {
    return unsafe
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
}
Enter fullscreen mode Exit fullscreen mode

or install the package, run:

npm install --save-dev escape-html @types/escape-html
Enter fullscreen mode Exit fullscreen mode

Then use am like this:

const safeMessage = escapeHtml(message);
Enter fullscreen mode Exit fullscreen mode

Then pass am:

html: `<p>${safeMessage}</p>`
Enter fullscreen mode Exit fullscreen mode

Recommended security improvements

Validate email inputs

const emailRegex =
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

if (!emailRegex.test(email)) {
    throw new Error("Invalid email");
}
Enter fullscreen mode Exit fullscreen mode

Limit input length

if (message.length > 5000) {
    throw new Error("Message too long");
}
Enter fullscreen mode Exit fullscreen mode

Rate limit requests

Mount security for your form make you secure am from spam.

Recommended:


Common errors and fixes

unauthorized_client

Cause:

  • OAuth consent no dey configured correctly
  • Gmail account no dey added as test user
  • Wrong OAuth client type

Fix:

  • Add yourself as a test user
  • Use Web Application OAuth client

redirect_uri_mismatch

Cause:

  • Missing redirect URI

Fix:
Add the local server with your desired port:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

to:

  • Authorized redirect URIs

invalid_grant

Cause:

  • Expired or revoked refresh token

Fix:

  • Generate a new refresh token

Username and password not accepted

Cause:

  • Using password auth instead of the OAuth2

Fix:

  • Use OAuth2 credentials

Production recommendations

For small forms like the portfolio/contact forms, the Gmail OAuth2 works well.

However, for scalable production apps, consider dedicated email providers like:

Advantages:

  • Better deliverability
  • Easier setup
  • Analytics
  • Fewer Gmail restrictions

Full code implementation

My full code implementation inside my Nuxt server component for my portfolio

import nodemailer from "nodemailer";
import escapeHtml from "escape-html";

export default defineEventHandler(async (event) => {
    const body = await readBody(event);

    if (!body) {
        throw createError({
            statusCode: 400,
            statusMessage: "Invalid request payload. Body is required.",
        });
    }

    const { name, email, subject, message, } = body;

    // Validation
    if (!name || typeof name !== "string" || !name.trim()) {
        throw createError({
            statusCode: 400,
            statusMessage: "Name is required and must be a valid text.",
        });
    }

    if (!email || typeof email !== "string" || !email.trim()) {
        throw createError({
            statusCode: 400,
            statusMessage: "Email address is required.",
        });
    }

    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email.trim())) {
        throw createError({
            statusCode: 400,
            statusMessage: "Please provide a valid email address.",
        });
    }

    if (!message || typeof message !== "string" || !message.trim()) {
        throw createError({
            statusCode: 400,
            statusMessage: "Message is required and must be a valid text.",
        });
    }

    if (message.length > 5000) {
        throw createError({
            statusCode: 400,
            statusMessage: "Message too long",
        });
    }

    const trimmedName = escapeHtml(name.trim());
    const trimmedEmail = escapeHtml(email.trim());
    const trimmedSubject = escapeHtml(subject.trim());
    const trimmedMessage = escapeHtml(message.trim());


    // Dispatching
    const user = process.env.SMTP_USER;
    const receiver = process.env.CONTACT_EMAIL_RECEIVER ?? user;
    const clientId = process.env.GOOGLE_CLIENT_ID;
    const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
    const refreshToken = process.env.GOOGLE_OAUTH_REFRESH_TOKEN;


    if (user && clientId && clientSecret && refreshToken) {
        try {
            const transporter = nodemailer.createTransport({
                service: "gmail",
                auth: {
                    type: "OAuth2",
                    user: user,
                    clientId: clientId,
                    clientSecret: clientSecret,
                    refreshToken: refreshToken,
                }
            })

            await transporter.sendMail({
                from: `"Portfolio Contact" <${user}>`,
                replyTo: trimmedEmail,
                to: receiver,
                subject: `${trimmedSubject ?? `Portfolio Message from ${trimmedName}`}`,
                text: `Name: ${trimmedName}\nEmail: ${trimmedEmail}\n\nMessage:\n${trimmedMessage}`,
                html: `
                    <div style="font-family: sans-serif; padding: 20px; color: #333; max-width: 600px; border: 1px solid #e2e8f0; border-radius: 12px;">
                        <h2 style="color: #235347; border-bottom: 2px solid #235347; padding-bottom: 8px;">New Contact Form Message</h2>
                        <p><strong>Name:</strong> ${trimmedName}</p>
                        <p><strong>Email:</strong> <a href="mailto:${trimmedEmail}">${trimmedEmail}</a></p>
                        <p><strong>Message:</strong></p>
                        <blockquote style="background: #f7fafc; padding: 15px; border-left: 4px solid #235347; margin: 0; white-space: pre-wrap;">${trimmedMessage}</blockquote>
                        <hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;" />
                        <p style="font-size: 0.875rem; color: #718096;">Sent from your Portfolio contact form.</p>
                    </div>
                `,
            });

            return {
                success: true,
                message: "Message sent successfully via SMTP!",
            };
        } catch (error: any) {
            console.error("SMTP sending error:", error);
            throw createError({
                statusCode: 500,
                statusMessage: `Failed to send email. Error: ${error.message || "SMTP Configuration Issue"}`,
            });
        }
    }
})
Enter fullscreen mode Exit fullscreen mode

Final thoughts

Setting up Gmail OAuth2 with Nodemailer fit be like say na heavy or too much work because say the following dey involved:

  • Google Cloud configuration
  • OAuth flows
  • API permissions
  • Token generation

But once you don configure am properly, e go provide a secure and production-ready email solution wey no need make you expose your Gmail password.

The key steps no pass:

  1. Enable Gmail API
  2. Configure OAuth consent
  3. Create OAuth credentials
  4. Generate a refresh token
  5. Configure Nodemailer correctly
  6. Use replyTo instead of arbitrary from addresses
  7. Escape user input to prevent injection

Once all these things don gather stand well, your application fit dey use Gmail and OAuth2 authentication dey send emails securely, you mind sef go dey at rest. I hope say you go find am usefully helpful.

Cheers๐Ÿฅ‚!

Top comments (0)