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
- Why use Gmail OAuth2?
- Prerequisites
- Creating a Google Cloud Project
- Enabling the Gmail API
- Configuring the OAuth Consent Screen
- Creating OAuth Credentials
- Installing Dependencies
- Generating a Refresh Token
- Configuring Nodemailer
- Sending Emails
- Escaping HTML and Preventing Injection
- Common Errors and Fixes
- Production Recommendations
- 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"
}
You go just do like this:
auth: {
type: "OAuth2",
user,
clientId,
clientSecret,
refreshToken
}
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:
Step 1: Create new project
- Click the project dropdown wey dey the top bar
- Click New Project
- Enter project name wey you want
- Click Create
Wait for the project make e initialize.
Enabling the Gmail API
Once your project don create:
- Open the APIs Library:
- Search for:
Gmail API
- Click the Gmail API
- 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:
Step 1: Choose user type
Choose:
External
Then click Create.
Step 2: Fill the app information
Provide:
- App name
- Support email
- Developer contact email
Example:
App Name: Portfolio Mailer
Step 3: Add test users
If your app still dey for testing mode:
- Open the Test Users section
- 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
Creating OAuth credentials
Now, na to create the OAuth credentials.
Go to:
Step 1: Create OAuth client ID
- Click Create Credentials
- Select OAuth Client ID
Step 2: Select application type
Choose:
Web Application
No go choose:
- Desktop App
- Android
- iOS
Step 3: Add redirect URI
Under the following section:
Authorized redirect URIs
Add:
http://localhost:3000
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
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
For TypeScript, also run:
npm install -D tsx typescript
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
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
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);
Step 2: Run the script
Using TypeScript, run:
npx tsx scripts/generate-refresh-token.ts
Or JavaScript:
node scripts/generate-refresh-token.js
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
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=...
Copy the code value.
Example:
4/0AX4XfWh...
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();
Step 6: Run the script again
npx tsx scripts/generate-refresh-token.ts
You suppose see something wey go be like this:
{
"access_token": "...",
"refresh_token": "...",
"scope": "https://mail.google.com/",
"token_type": "Bearer"
}
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,
},
});
}
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>
`,
});
Important: Use replyTo instead of from
This one na one common mistake wey fit happen.
No go do like this:
from: "visitor@example.com"
Gmail fit reject am, e dey get choko sometimes.
Instead, do am like this:
from: `"Portfolio Contact" <${user}>`,
replyTo: visitorEmail,
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>`
Create an escapeHtml helper function, this way dey safe enough:
function escapeHtml(unsafe: string) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
or install the package, run:
npm install --save-dev escape-html @types/escape-html
Then use am like this:
const safeMessage = escapeHtml(message);
Then pass am:
html: `<p>${safeMessage}</p>`
Recommended security improvements
Validate email inputs
const emailRegex =
/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
throw new Error("Invalid email");
}
Limit input length
if (message.length > 5000) {
throw new Error("Message too long");
}
Rate limit requests
Mount security for your form make you secure am from spam.
Recommended:
- Cloudflare Turnstile
- reCAPTCHA
- IP rate limiting
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
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"}`,
});
}
}
})
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:
- Enable Gmail API
- Configure OAuth consent
- Create OAuth credentials
- Generate a refresh token
- Configure Nodemailer correctly
- Use
replyToinstead of arbitraryfromaddresses - 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)