DEV Community

Cover image for I Tried to Send Emails Using Gmail SMTP, Here's What Actually Worked
Likhit Kumar V P
Likhit Kumar V P

Posted on

I Tried to Send Emails Using Gmail SMTP, Here's What Actually Worked

I was setting up email sending for a side project recently, nothing fancy, just a welcome email when a user signs up. Grabbed a Nodemailer tutorial, followed the steps, and ran straight into Error: Invalid login: 535-5.7.8 Username and Password not accepted.

Turns out the tutorial was outdated. Gmail's rules have changed, and a surprising amount of advice floating around the internet just doesn't work anymore.

So here's what actually works in 2026.


What I Was Trying to Do

My stack was simple: a Node.js backend, no paid services, and a Gmail account I already had. The plan was to use Gmail SMTP with Nodemailer that's free, no sign-ups, no credit cards. Should've been straightforward.

Most tutorials I found were written in 2020 or 2021. Gmail's rules have changed a lot since then, and a good chunk of that advice is now broken. Let me clear up what's dead and what still works.


First, The Things That Are Dead (Don't Even Try)

Before I tell you what works, let me tell you what I wasted time on so you don't have to.

The "Less Secure Apps" toggle - Every old tutorial mentioned this. Go to your Google account settings, flip this switch, done. Except when I looked for it, it simply didn't exist. Turns out Google permanently removed it back in May 2022. It's gone.

Using my actual Gmail password - My first instinct was just to throw my email and password into the Nodemailer config like this:

auth: {
  user: 'me@gmail.com',
  pass: 'myActualPassword123', // ❌ This will NOT work
}
Enter fullscreen mode Exit fullscreen mode

Instant rejection. Google no longer accepts your real password over SMTP for accounts with 2-Step Verification. It feels like it should work but it doesn't.

After burning time on both of those dead ends, I finally found the two approaches that actually work in 2026.


Method 1: App Passwords - The Quick Fix That Got Me Unblocked

The first thing that actually worked for me was App Passwords. An App Password is a special 16-character code that Google generates for your account. It's separate from your real password and designed specifically for apps and scripts that need SMTP access.

Here's how I set it up.

Step 1 : Enable 2-Step Verification

App Passwords only exist on accounts with 2-Step Verification turned on. If you haven't done this already:

  1. Go to myaccount.google.com
  2. Click Security in the left sidebar
  3. Under "How you sign in to Google", click 2-Step Verification and follow the setup

Step 2 : Generate an App Password

  1. Back in Security, scroll down and click App passwords (Can't see it? 2-Step Verification isn't active yet)
  2. Give it a name "Nodemailer Side Project"
  3. Click Create
  4. Copy the 16-character code. Write it down somewhere as you won't see it again

Step 3 : Wire It Into Nodemailer

I created a .env file first :

GMAIL_USER=me@gmail.com
GMAIL_APP_PASSWORD=abcdefghijklmnop
Enter fullscreen mode Exit fullscreen mode

Heads up: Google displays the App Password with spaces like abcd efgh ijkl mnop. Remove the spaces when you paste it into your .env.

Then the Nodemailer config:

require('dotenv').config();
const nodemailer = require('nodemailer');

const transporter = nodemailer.createTransport({
  service: 'gmail',
  auth: {
    user: process.env.GMAIL_USER,
    pass: process.env.GMAIL_APP_PASSWORD,
  },
});

async function sendEmail() {
  try {
    const info = await transporter.sendMail({
      from: `"My App" <${process.env.GMAIL_USER}>`,
      to: 'recipient@example.com',
      subject: 'It works!',
      html: '<p>Finally. <strong>It works.</strong></p>',
    });
    console.log('Email sent! Message ID:', info.messageId);
  } catch (error) {
    console.error('Error:', error);
  }
}

sendEmail();
Enter fullscreen mode Exit fullscreen mode

And it worked. First email landed in the inbox cleanly.

The Gotcha That Caught Me (On My Cloud Server)

Everything worked perfectly on my local machine. Then I deployed to my VPS and it broke again with same Invalid login error as before. I nearly lost my mind.

What was happening: Gmail's security systems saw a login attempt from a cloud server in a different country from my usual location and blocked it. The fix was weirdly simple, I visited accounts.google.com/DisplayUnlockCaptcha while signed into my Google account, clicked Allow, and then the server could connect fine.

I tried to set the from field to noreply@myapp.com to look more professional. Gmail silently replaced it with my actual @gmail.com address on every single email. You cannot send as a custom from address via Gmail SMTP on a personal account. Your emails will always come from your Gmail.


But Then I Read the Fine Print

After the relief wore off, I went back and read Google's own documentation more carefully. And there it was:

"App passwords are not recommended and are unnecessary in most cases."

Google isn't planning to keep App Passwords around forever. They're actively pushing developers toward OAuth 2.0, and at some point App Passwords will likely be retired with no concrete date given.

For my side project at 3 AM, App Passwords were the right call to get unblocked. But I knew I needed to come back and do this properly. So the next day, I set up OAuth 2.0.


Method 2: OAuth 2.0

OAuth 2.0 takes maybe 20 minutes to set up the first time, but it's Google's actual recommended path and won't suddenly break when they pull the plug on App Passwords. Instead of storing a password, you go through a one-time authorization flow and get a refresh token. Nodemailer uses this to generate short-lived access tokens automatically.

You'll need one extra package:

npm install googleapis
Enter fullscreen mode Exit fullscreen mode

Step 1 : Create a Google Cloud Project

  1. Go to console.cloud.google.com
  2. Click Select a projectNew Project
  3. Name it something like my-app-email and hit Create
  4. Make sure it's selected in the top bar before continuing

Step 2 : Enable the Gmail API

  1. In the left menu go to APIs & ServicesLibrary
  2. Search for Gmail API, click it, and hit Enable

Step 3 : Configure the OAuth Consent Screen

This part tripped me up the first time because the UI is a bit overwhelming. Here's what actually matters:

  1. Go to APIs & ServicesOAuth consent screen
  2. Choose External, click Create
  3. Fill in App name, support email, and developer contact email.
  4. On the Scopes step, click Add or Remove Scopes, manually type https://mail.google.com/ into the field, click Add to Table, then Update
  5. On the Test users step, this is the part I initially skipped and then wondered why nothing worked. add your own Gmail address here
  6. Save and continue through to the end

Step 4 : Create OAuth Credentials

  1. Go to APIs & ServicesCredentials
  2. Click + Create CredentialsOAuth client ID
  3. Choose Web application
  4. Under Authorized redirect URIs add exactly this: https://developers.google.com/oauthplayground
  5. Click Create and copy your Client ID and Client Secret

Step 5 : Get Your Refresh Token via OAuth Playground

This is the clever bit. Google has a tool called OAuth Playground that lets you authorize scopes and grab tokens without writing any extra code.

  1. Go to developers.google.com/oauthplayground
  2. Click the ⚙️ gear icon in the top right
  3. Check "Use your own OAuth credentials" and enter your Client ID and Client Secret
  4. In the left panel, scroll to Gmail API v1 and select https://mail.google.com/
  5. Click Authorize APIs → sign in with your Gmail account → allow access
  6. Click Exchange authorization code for tokens
  7. Copy the Refresh Token from the response

Important thing I learned the hard way: While your app is in "Testing" mode on Google Cloud Console, refresh tokens expire after 7 days. You'll need to regenerate them periodically. For a personal or internal tool that's totally manageable just set a calendar reminder. To get permanent tokens, you'd need to publish the app, which for sensitive scopes like Gmail requires a Google review.

Step 6 : Update .env and Write the Code

GMAIL_USER=me@gmail.com
GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_client_secret
GOOGLE_REFRESH_TOKEN=your_refresh_token
Enter fullscreen mode Exit fullscreen mode
require('dotenv').config();
const nodemailer = require('nodemailer');
const { google } = require('googleapis');

const OAuth2 = google.auth.OAuth2;

async function createTransporter() {
  const oauth2Client = new OAuth2(
    process.env.GOOGLE_CLIENT_ID,
    process.env.GOOGLE_CLIENT_SECRET,
    'https://developers.google.com/oauthplayground'
  );

  oauth2Client.setCredentials({
    refresh_token: process.env.GOOGLE_REFRESH_TOKEN,
  });

  const accessToken = await new Promise((resolve, reject) => {
    oauth2Client.getAccessToken((err, token) => {
      if (err) reject(new Error('Failed to get access token: ' + err));
      resolve(token);
    });
  });

  return nodemailer.createTransport({
    service: 'gmail',
    auth: {
      type: 'OAuth2',
      user: process.env.GMAIL_USER,
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      refreshToken: process.env.GOOGLE_REFRESH_TOKEN,
      accessToken: accessToken,
    },
  });
}

async function sendEmail() {
  try {
    const transporter = await createTransporter();

    await transporter.verify(); // Sanity check before sending
    console.log('Connected to Gmail ✓');

    const info = await transporter.sendMail({
      from: `"My App" <${process.env.GMAIL_USER}>`,
      to: 'recipient@example.com',
      subject: 'Hello via OAuth 2.0',
      html: '<p>Sent properly this time, with <strong>OAuth 2.0</strong>.</p>',
    });

    console.log('Email sent:', info.messageId);
  } catch (error) {
    console.error('Error:', error);
  }
}

sendEmail();
Enter fullscreen mode Exit fullscreen mode

Which One Should You Use?

App Password OAuth 2.0
Setup time ~5 minutes ~20 minutes
Works in 2026? ✅ Yes ✅ Yes
Future-proof? ⚠️ Uncertain ✅ Google-recommended
Token expiry Never 7 days (Testing mode)
Best for Dev / quick prototypes Anything in production

My rule: App Password to get something working locally, OAuth 2.0 before anyone else touches it.


Don't Forget This Before You Push to Git

The first time I set all this up I nearly committed my .env file. Do this immediately after creating it:

echo ".env" >> .gitignore
Enter fullscreen mode Exit fullscreen mode

If your credentials ever get exposed, here's how to recover fast:


Know When to Move On From Gmail

Gmail is brilliant for getting off the ground, but it has real ceilings around 500 emails/day on a free account. Each recipient counts individually, so one email to 5 people eats 5 of your daily quota. Hit the limit and you'll see SMTP error 454 4.7.0 until the quota resets the next day.

If you find yourself hitting that ceiling, or you need a custom from address like hello@yourapp.com, or you want delivery analytics, it's time to look at Resend, Postmark, or Brevo. All have free tiers that are genuinely useful for indie hackers and small teams, and they won't quietly block your cloud server IP.


Hope this saves you the 3 AM spiral. Drop a comment if you get stuck on any step I've probably hit that exact wall too.


Top comments (2)

Collapse
 
trinhcuong-ast profile image
Kai Alder

Solid writeup, especially the cloud server gotcha — that one caught me off guard too when I first deployed a Node app on a VPS. The DisplayUnlockCaptcha trick saved me hours of debugging.

One thing worth mentioning for folks reading this: if you go the OAuth route and your app stays in "Testing" mode, you can only add up to 100 test users. For a personal project that's fine, but if you're building something for a small team it can sneak up on you.

Also, for anyone who doesn't want to deal with token refresh at all — Resend has been rock solid for me. Their free tier gives you 3k emails/month with basically zero config. Just an API key and you're sending. Might be worth adding as a "Method 3" for people who just want it to work without the Google Cloud Console dance.

Collapse
 
likhit profile image
Likhit Kumar V P

The VPS struggle is real! Nothing like thinking your code is perfect only to realize the cloud provider is silently dropping Port 587 traffic.

Really glad you mentioned the Testing mode limits, I've seen so many devs hit that wall. For those who want to skip the drama entirely, I second the Resend recommendation. Sometimes the best way to win the 'Google Cloud Console dance' is simply not to play.