Skip to content
Skip to main content

Send OAuth email in PHP

Last updated:

Sending mail from a user’s own Gmail or Outlook mailbox in PHP used to mean username and password over SMTP. That door is closed: Google and Microsoft both retired basic SMTP auth, and the only supported path is OAuth. In PHP that pushes you toward PHPMailer’s XOAUTH2 support, the league/oauth2-client package, and a token store you build and refresh yourself.

This guide shows the other path. Instead of wiring XOAUTH2 into an SMTP transport, you send one authenticated REST request from PHP with curl or Guzzle, and the OAuth token lifecycle stays out of your code.

How do I send authenticated email in PHP using SMTP with OAuth?

Section titled “How do I send authenticated email in PHP using SMTP with OAuth?”

You have two real options. The first wires OAuth into an SMTP client like PHPMailer using XOAUTH2, where you fetch an access token, refresh it before each send, and feed it to the transport. The second skips SMTP entirely and sends through a REST API call, so PHP makes one HTTPS request and the token never touches your code.

PHPMailer added XOAUTH2 in version 6.0, and it works. The cost is the plumbing around it. You install phpmailer/phpmailer plus league/oauth2-client and a provider package, register an OAuth app with Google or Microsoft, store the refresh token somewhere durable, and run a refresh whenever the 1-hour Google access token expires. Every mailbox you add repeats that token bookkeeping. The REST approach moves the OAuth handshake and refresh behind one connected account, so your mail() replacement is a single POST with a JSON body. The PHPMailer wiki on OAuth documents the SMTP route in full if you want to compare line by line.

A REST send call replaces the SMTP transport, the XOAUTH2 token provider, and the refresh-token store with one authenticated HTTPS request. PHPMailer with XOAUTH2 still works for a single mailbox you control, but the token bookkeeping grows with every account you connect, and SMTP ports stay in the failure path.

The two approaches diverge on where OAuth lives. With PHPMailer you own the token: you fetch it, cache it, refresh it before the 1-hour expiry, and handle the 535 auth failure when refresh slips. With a REST call, one connected grant holds the OAuth credentials, refresh runs automatically, and your PHP sends through port 443 instead of the SMTP port 587 that many hosts block. The table compares the work each approach actually requires.

TaskPHPMailer + XOAUTH2REST send call
OAuth handshakeleague/oauth2-client plus provider packageHandled by the connected grant
Token refreshYour code, before each sendAutomatic
TransportSMTP over port 587 (often blocked)HTTPS on port 443
Multiple mailboxesOne token store per accountOne grant per account, same call
Send call$mail->send() after token setupOne POST /v3/grants/{grant_id}/messages/send

The send call posts to POST /v3/grants/{grant_id}/messages/send with a JSON body holding subject, body, and a to array. The grant represents the connected Gmail or Outlook mailbox, so the message sends from that user’s address with no SMTP transport. PHP’s built-in cURL extension is enough, no Composer package required.

This is the minimum send. The to field is an array of objects with name and email, and the API returns a 200 with the new message’s id and grant_id on success. You authenticate with your API key as a bearer token, never the user’s OAuth credentials.

Guzzle wraps the same POST /v3/grants/{grant_id}/messages/send request in a cleaner client, which suits Laravel and Symfony apps that already pull in guzzlehttp/guzzle. The request body is identical: a JSON object with to, subject, and body. Guzzle throws on non-2xx responses, so you catch RequestException instead of checking a status code by hand.

This version adds cc and reply_to, both arrays of address objects the send endpoint accepts. Guzzle’s json option encodes the body and sets the Content-Type header in one step, which trims the boilerplate from the cURL version above.

How do I avoid sending duplicate messages on retry?

Section titled “How do I avoid sending duplicate messages on retry?”

Pass an Idempotency-Key header with a unique value, and the API caches the result for 1 hour scoped to the grant. A retry with the same key and the same payload returns the cached response, marked with the Idempotent-Response: true header, instead of sending a second copy. This matters when a network timeout leaves you unsure whether the first send landed.

PHP web requests time out, queues retry jobs, and load balancers replay requests, so duplicate sends are a real risk in production. Generate a UUID per logical message, store it with the job, and reuse it on every retry of that job. A Idempotency-Key over 256 characters is rejected, so keep it to a standard UUID. The idempotent send reference covers the cached error behavior and how the 1-hour window is enforced. Add the header in Guzzle through the headers array, or in cURL alongside the existing Authorization and Content-Type lines.

When does wiring XOAUTH2 into PHPMailer make sense?

Section titled “When does wiring XOAUTH2 into PHPMailer make sense?”

Wiring XOAUTH2 into PHPMailer makes sense when you send from one mailbox you fully own, can’t add an outbound dependency, and already run your own OAuth refresh job. In that narrow case, PHPMailer plus league/oauth2-client keeps everything in-process, and the one-time token setup is a fixed cost you pay once.

Be honest about where it stops paying off. The moment you send from many users’ mailboxes, you maintain a refresh-token store per account, handle the per-account 535 auth failures when a token expires, and keep SMTP port 587 reachable from your host. Many shared and serverless PHP environments block outbound SMTP, which is why SMTP ports get blocked so often. A REST send over port 443 sidesteps all of it, and one connected grant per user replaces the token store. If you only ever send from a single static address, though, PHPMailer is a reasonable answer.

Before any send works, the target mailbox needs a grant, which is the connected-account record holding the OAuth tokens. You create it once through the hosted OAuth flow: redirect the user to /v3/connect/auth, they approve access, and the callback exchanges the code at /v3/connect/token for a grant_id. You store that ID, not the OAuth tokens.

The send endpoint needs the right OAuth scope on the grant. Google requires gmail.send at minimum, and Microsoft requires Mail.ReadWrite (or Mail.ReadWrite.Shared for shared mailboxes). Request these when you set up the connector so the first send doesn’t fail on a scope error. The Gmail and Outlook OAuth guide walks through the redirect, callback, and code exchange in detail, including the PHP-friendly hosted flow that keeps client secrets off the browser.