A step-by-step guide for developers who are not Exchange admins: stop using a mailbox password, send through the Graph API, and restrict the app to a single mailbox with Exchange RBAC for Applications.
Last verified: October 2026. Microsoft’s admin portals change often, so check menu names against the current docs if something looks different.
TL;DR
If your Laravel app sends mail through smtp.office365.com and fails with 535 5.7.3 Authentication unsuccessful, you have two options:
- Quick fix(not recommended for production): turn off Security Defaults in Microsoft Entra. This fixed my login, but it removes MFA enforcement and legacy sign-in blocking for your whole tenant.
- Right fix (this article): stop using a mailbox password. Send through the Microsoft Graph API with an app that can only send as one shared mailbox (noreply@yourdomain.com), using a small custom Laravel mail transport. Your existing Mailables and Notifications work unchanged.
By the end you will have:
- a dedicated noreply@yourdomain.com shared mailbox,
- an Entra app that can send only as that mailbox,
- a Laravel graph mailer that works with all your existing Mailables, Notifications and queues, with no code changes in them.
I did this on a real project, hit every wall, and wrote down the fixes.
1. The problem
My Laravel API sent verification emails through Microsoft 365 over SMTP. Then this happened:
> AUTH LOGIN
< 334 VXNlcm5hbWU6
> …
< 535 5.7.3 Authentication unsuccessful
curl: (67) Login denied
I reproduced it outside Laravel with plain curl so I could rule out my app:
curl -v \
--url "smtp://$SMTP_HOST:$SMTP_PORT" \
--ssl-reqd \
--user "$SMTP_USERNAME:$SMTP_PASSWORD" \
--mail-from "$MAIL_FROM" \
--mail-rcpt "$MAIL_TO" \
--upload-file <(printf 'From: %s\r\nTo: %s\r\nSubject: SMTP Test\r\n\r\nTest.\r\n' "$MAIL_FROM" "$MAIL_TO")
The connection and TLS were fine. Microsoft simply refused the login, so the network and port 587 were not the problem.
2. Why Microsoft rejects the login
Microsoft’s SMTP AUTH documentation lists three things that block SMTP AUTH even when your credentials are correct:
- Security Defaults is on. SMTP AUTH is already disabled, and mailbox settings don’t override it.
- SMTP AUTH is switched off for the organization or the mailbox. The mailbox setting wins over the organization one.
- An authentication policy blocks Basic authentication for SMTP.
Security Defaults is a free bundle of tenant-wide protections from Microsoft: MFA registration, MFA for admins, and blocking of legacy authentication, which includes SMTP, IMAP and POP3. That last rule stops an app from logging in with a username and password. The switch is under Entra ID → Overview → Properties → Manage security defaults.
What fixed it for me: turning Security Defaults off. Nothing else changed, and I never switched SMTP AUTH on myself. Afterwards I ran this check on my mailbox:
Get-CASMailbox -Identity <mailbox> | Format-List SmtpClientAuthenticationDisabled
It returned False, which means SMTP AUTH is enabled for that mailbox. Run it on yours instead of assuming.
Why it’s only a bridge. Turning Security Defaults off removes MFA enforcement and legacy sign-in blocking for your whole tenant. Microsoft’s advice is to replace them with Conditional Access. And under its updated timeline, SMTP Basic authentication will be disabled by default for existing tenants at the end of December 2026. Admins can still turn it back on, and the final removal date will be announced in the second half of 2027. These dates have moved before, so check the page again before you deploy.
3. The better design
Instead of logging in as a person, your app gets its own identity: a client ID and a client secret. It trades them for a short-lived token and sends mail through the Microsoft Graph sendMail API.
Laravel Queue Worker
│
│ 1. client_id + client_secret
▼
Microsoft Entra ID
│
│ 2. access token
▼
Microsoft Graph
│
│ 3. POST /users/noreply@yourdomain.com/sendMail
│ Authorization: Bearer <token>
▼
Exchange Online
│
│ Exchange RBAC
│ ┌─────────────────────────────┐
└──► noreply@yourdomain.com ONLY
└─────────────────────────────┘
│
▼
Recipient
Why this is better:
- No mailbox password in your .env. The secret expires, and you can rotate it without touching any mailbox.
- No SMTP, so Security Defaults can stay on. Graph doesn’t use SMTP AUTH, and I re-ran the test with Security Defaults enabled: it still returned 202 Accepted.
- It can be locked to one mailbox. This is the part most tutorials skip.
The mistake most tutorials make
The usual route is to add the Microsoft Graph Mail.Send application permission in Entra and grant admin consent. It works, but that permission lets the app send mail as any user in your organization. If the client secret leaks, someone can send email as your CEO.
Microsoft’s fix is RBAC for Applications in Exchange Online, which replaces the older Application Access Policies. You grant the send permission inside Exchange, limited to the mailboxes you choose.
One rule: don’t also grant Mail.Send in Entra. Microsoft's FAQ says the two grants add up, so an unscoped Entra grant cancels your restriction. This guide adds no API permissions in Entra.
4. Prerequisites
- A Microsoft 365 tenant with Exchange Online, and an admin account. A Global Administrator covers everything in this guide. Microsoft’s RBAC for Applications article lists the minimum roles if you want a narrower account. If your tenant came through a reseller such as GoDaddy, see the note in Step 1.
- A Laravel app. I tested on Laravel 11. It should work on any version that uses Symfony Mailer (Laravel 9 and newer), but I haven’t tried them, so run the included test on yours.
- PowerShell 7 on a Mac, Linux or Windows machine. Microsoft documents the mailbox-restriction step only in PowerShell, so you need it once.
5. Step by step
Step 1: Create the sending mailbox
Use a shared mailbox. Nobody can sign in to it with a password, so there is nothing to leak, and basic shared mailboxes don’t need their own license.
- Open the Exchange admin center: https://admin.cloud.microsoft/exchange
- Go to Recipients → Mailboxes → Add a shared mailbox.
- Display name: your brand name, for example Your Brand. Recipients usually see this as the sender name.
- Email address: noreply, then pick your primary domain from the dropdown (for example yourdomain.com).
- Alias: leave it blank.
- Create it. It may take a few minutes to appear.
Reseller tenants (GoDaddy etc.): In my tenant the “Microsoft 365 admin center” link inside the Exchange admin center redirected to GoDaddy. You don’t need it. Everything in this guide uses the Exchange admin center, the Entra admin center and PowerShell.
Step 2: Register the app in Microsoft Entra
- Go to https://entra.microsoft.com → Entra ID → App registrations → New registration.
- Name: your-app-mailer. It's a label for admins, not the sender name.
- Supported account types: Single tenant only.
- Redirect URI: leave empty.
- Click Register.
- On the Overview page, copy the Application (client) ID and the Directory (tenant) ID.
Go to Certificates & secrets → Client secrets → New client secret. Add a description and choose an expiry. Microsoft recommends less than 12 months, so I use 6. Click Add.
Copy the Value immediately. Microsoft never shows it again once you leave the page.
Pitfall: There are two columns, Value and Secret ID. You need Value. The Secret ID is just a label, and using it gives you an invalid-client error.
A note on secrets in production: Microsoft says client secrets are less secure than certificates and recommends certificates for production applications. A secret is the simplest way to get started, and in this guide the damage a leaked secret can do is limited to one mailbox (Step 4). If your security requirements are stricter, switch to a certificate once this works.
Do not open “API permissions” and add anything. The permission comes from Exchange in Step 4.
Step 3: Get the Enterprise App Object ID
Exchange needs the object ID of the app’s service principal , which is not the Object ID shown on the App registrations page.
- In Entra, go to Entra ID → Enterprise apps and click your app.
- Copy the Object ID.
Microsoft’s docs call this out explicitly: the Object ID on the App registrations page is a different value. Using the wrong one is the easiest mistake in this whole process.
Step 4: Restrict the app to one mailbox (PowerShell)
This step limits what the app is allowed to do. Microsoft documents it in PowerShell, so install that first. The steps below are for macOS.
Install PowerShell 7
Download the signed .pkg for your Mac from Microsoft's PowerShell releases page (osx-arm64 for Apple silicon, osx-x64 for Intel), then double-click it. This is the method Microsoft recommends.
To check the download is genuine (your file name will differ):
pkgutil --check-signature powershell-7.x.x-osx-x64.pkg
Look for Developer ID Installer: Microsoft Corporation and Notarization: trusted by the Apple notary service.
Homebrew works too (brew install — cask powershell), but Microsoft lists it as a third-party method.
Start PowerShell and connect to Exchange Online:
pwsh
Install-Module ExchangeOnlineManagement -Scope CurrentUser
Connect-ExchangeOnline
A browser window opens. Sign in with your admin account.
First, prove your filter matches exactly one mailbox:
Get-Mailbox -RecipientTypeDetails SharedMailbox `
-Filter "PrimarySmtpAddress -eq 'noreply@yourdomain.com'" |
Format-Table Name,PrimarySmtpAddress
You must see exactly one row: your noreply@ mailbox. If you see none or several, stop and fix the filter.
Then create the scope and the role assignment. These commands follow Microsoft’s RBAC for Applications documentation:
# 1. Tell Exchange about the Entra service principal
New-ServicePrincipal -AppId <APPLICATION_CLIENT_ID> `
-ObjectId <ENTERPRISE_APP_OBJECT_ID> `
-DisplayName "your-app-mailer"
# 2. Define the set of mailboxes the app may touch
New-ManagementScope -Name "NoReplyOnly" `
-RecipientRestrictionFilter "PrimarySmtpAddress -eq 'noreply@yourdomain.com'"
# 3. Grant "send mail" only within that scope
New-ManagementRoleAssignment -App <ENTERPRISE_APP_OBJECT_ID> `
-Role "Application Mail.Send" `
-CustomResourceScope "NoReplyOnly"
Verify with Microsoft’s test cmdlet. Check the allowed mailbox and a mailbox that should be blocked:
Test-ServicePrincipalAuthorization -Identity <ENTERPRISE_APP_OBJECT_ID> -Resource noreply@yourdomain.com | Format-Table
Test-ServicePrincipalAuthorization -Identity <ENTERPRISE_APP_OBJECT_ID> -Resource someone-else@yourdomain.com | Format-Table
Here is what mine returned:
RoleName GrantedPermissions AllowedResourceScope ScopeType InScope
-------- ------------------ -------------------- --------- -------
Application Mail.Send Mail.Send NoReplyOnly CustomRecipientScope True ← noreply@
Application Mail.Send Mail.Send NoReplyOnly CustomRecipientScope False ← another mailbox
True for the shared mailbox, False for everyone else.
Disconnect when you are done:
Disconnect-ExchangeOnline -Confirm:$false
Propagation delay: Microsoft says permission changes can take roughly 30 minutes to 2 hours to take effect, depending on how recently the app was used. The test command above bypasses that delay, but real API calls don’t. If your first send fails with a 403, wait before you debug.
Step 5: Test the send with curl before touching Laravel
Always test the Microsoft side in isolation first. Set the values in your shell. In zsh, read -s keeps the secret out of your shell history and screen:
export TENANT_ID='<directory (tenant) id>'
export CLIENT_ID='<application (client) id>'
# zsh (default on macOS):
read -s "CLIENT_SECRET?Secret value: " && export CLIENT_SECRET
# bash (most Linux):
read -s -p "Secret value: " CLIENT_SECRET && export CLIENT_SECRET
Get an access token (the client-credentials flow):
TOKEN=$(curl -s -X POST "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \
-d "client_id=$CLIENT_ID" -d "client_secret=$CLIENT_SECRET" \
-d "scope=https://graph.microsoft.com/.default" -d "grant_type=client_credentials" \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token','FAILED'))")
echo ${TOKEN:0:20}
You should see about 20 characters of a token. If the command crashes or prints FAILED, one of the three variables is empty or wrong. Check them with:
echo "[$TENANT_ID]" "[$CLIENT_ID]" ${#CLIENT_SECRET}
Send an email:
curl -i -X POST "https://graph.microsoft.com/v1.0/users/noreply@yourdomain.com/sendMail" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"message":{"subject":"Graph test","body":{"contentType":"Text","content":"It works."},"toRecipients":[{"emailAddress":{"address":"you@example.com"}}]}}'
Expected: HTTP/1.1 202 Accepted. Per Microsoft, 202 means the request was accepted. It doesn't guarantee delivery; delivery is still subject to Exchange Online limits and throttling.
6. Wiring it into Laravel
Laravel has no built-in Microsoft Graph mail driver, but it sends mail through Symfony Mailer, which accepts custom transports. The transport below takes the finished MIME message and posts it to Graph, so your existing Mailables and Notifications work without changes. Ready-made packages exist too, such as InnoGE/laravel-msgraph-mail and maize-tech/laravel-msgraph-mailer. I wrote a small one (under 70 lines) to avoid another dependency. The Microsoft setup is identical either way.
6.1 The transport
app/Mail/Transport/GraphTransport.php
<?php
namespace App\Mail\Transport;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
class GraphTransport extends AbstractTransport
{
public function __construct(
private readonly string $tenantId,
private readonly string $clientId,
private readonly string $clientSecret,
private readonly string $sender,
) {
parent::__construct();
}
protected function doSend(SentMessage $message): void
{
$response = Http::withToken($this->accessToken())
->withBody(base64_encode($message->toString()), 'text/plain')
->post("https://graph.microsoft.com/v1.0/users/{$this->sender}/sendMail");
if ($response->status() !== 202) {
throw new TransportException(
"Microsoft Graph sendMail failed ({$response->status()}): ".$response->body()
);
}
}
private function accessToken(): string
{
return Cache::remember("graph-mail-token:{$this->clientId}", now()->addMinutes(50), function () {
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials',
]
);
if ($response->failed() || ! $response->json('access_token')) {
throw new TransportException(
"Microsoft Graph token request failed ({$response->status()}): ".$response->body()
);
}
return $response->json('access_token');
});
}
public function __toString(): string
{
return 'graph';
}
}
How it works:
- Raw MIME in, 202 out. It posts the whole message base64-encoded, which is Graph's MIME mode. That's why attachments, headers and HTML/text versions survive. Graph answers 202 Accepted when it takes the message, and anything else becomes a TransportException, so Laravel reports a failed send instead of losing the email.
- Token cache. Microsoft’s documentation says access tokens last a random 60–90 minutes by default, so a 50-minute cache always expires first.
- Sender. The mailbox in the URL is the one Graph sends from. It must be the mailbox you scoped in Step 4.
6.2 Register the driver
app/Providers/AppServiceProvider.php
use App\Mail\Transport\GraphTransport;
use Illuminate\Support\Facades\Mail;
public function boot(): void
{
Mail::extend('graph', fn (array $config) => new GraphTransport(
$config['tenant_id'],
$config['client_id'],
$config['client_secret'],
$config['sender'],
));
}
6.3 Add the mailer
In config/mail.php inside the mailers array:
'graph' => [
'transport' => 'graph',
'tenant_id' => env('MS_GRAPH_TENANT_ID'),
'client_id' => env('MS_GRAPH_CLIENT_ID'),
'client_secret' => env('MS_GRAPH_CLIENT_SECRET'),
'sender' => env('MAIL_FROM_ADDRESS'),
],
6.4 Environment variables
MAIL_MAILER=graph
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="${APP_NAME}"
MS_GRAPH_TENANT_ID=<directory (tenant) id>
MS_GRAPH_CLIENT_ID=<application (client) id>
MS_GRAPH_CLIENT_SECRET=<secret VALUE>
Remove the old MAIL_HOST, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD and MAIL_ENCRYPTION lines. MAIL_FROM_ADDRESS must be the mailbox you scoped in Step 4. Add the three MS_GRAPH_* keys, empty, to .env.example, and keep real values in your host's secret store, never in git.
6.5 Apply the changes
Laravel’s queue workers read your configuration once, when they start. After editing mail settings, clear cached config and restart them:
php artisan config:clear
php artisan queue:restart # plain queue workers
php artisan horizon:terminate # if you use Horizon (your process manager restarts it)
Then confirm Laravel sees the new driver:
php artisan config:show mail.default # should print: graph
If mail still goes out the old way, look for a MAIL_* variable set outside .env (Docker Compose, your host's environment settings, a CI secret). Real environment variables win over .env.
Finally, send yourself a test:
php artisan tinker --execute="Mail::raw('Laravel Graph test', fn(\$m) => \$m->to('you@example.com')->subject('Laravel test'));"
7. Troubleshooting
- Token request fails or returns nothing. Check TENANT_ID, CLIENT_ID and the secret for typos or empty values. Also check that you copied the secret's Value , not its Secret ID, and that the secret hasn't expired. Run the raw token request from Step 5 to see Microsoft's error message.
- 403 right after setup. Microsoft says permission changes can take roughly 30 minutes to 2 hours to apply. Wait before debugging.
- 403 that doesn't go away. Make sure you used the Enterprise app Object ID (Step 3) in the Step 4 commands, and that MAIL_FROM_ADDRESS is the mailbox you scoped.
- The app can send as other mailboxes. You likely also granted Mail.Send under API permissions in Entra. Microsoft says the Entra grant and the Exchange grant add up, so remove it from Entra and keep only the Exchange one.
- Emails still arrive from the old sender. Your workers are running old configuration. Run the commands in “Apply the changes” in section 6.
- 202 Accepted but nothing in the inbox. 202 only means Graph accepted the message. Check spam, run a message trace in the Exchange admin center, and make sure SPF, DKIM and DMARC are set up for your domain.









Top comments (0)