Disclaimer: I no longer have access to an Office 365 Exchange Online environment, so I can’t re-verify some of the technical details in this post, especially the Exchange configuration steps. Microsoft’s portals, permissions and policies change over time, so please check them against Microsoft’s current documentation. The code and instructions are provided as-is, without any guarantee.
This post is a follow-up to How To: Secure Delphi SMTP With OAuth2 and corrects some inaccuracies in it.
Microsoft is phasing out Basic Authentication (username and password) for SMTP AUTH client submission in Exchange Online. If your Delphi or Lazarus application sends mail through smtp.office365.com, it needs OAuth2.
This post shows the app-only flow (client credentials): your application authenticates as itself, without a signed-in user. You will use Indy’s TIdHTTP to get an access token and TIdSMTP to send the mail, in three steps:
- Register an Entra ID app and configure Exchange Online.
- Request an access token with
TIdHTTP. - Send the message with
TIdSMTPusing the XOAUTH2 SASL mechanism.
Prerequisites
- A Microsoft 365 tenant with an Exchange Online mailbox, and an account that can grant admin consent and run Exchange PowerShell.
- Delphi or Free Pascal / Lazarus with Indy 10.6.x. The sample project uses Lazarus 4 / Free Pascal 3.2.2 in Delphi compatibility mode and Indy 10.6.3.10.
- The 64-bit OpenSSL DLLs from the Indy OpenSSL-Binaries repository next to your executable.
Which flow should I use? This post covers the app-only flow, which suits services and batch jobs that send from a shared mailbox. If you need to send as the signed-in user, use the delegated flow instead (permission
SMTP.Send, authorization code grant with a redirect URI, andoffline_accessfor refresh tokens). The two flows use different permissions and scopes, so don’t mix them. The Microsoft GraphsendMailAPI is a third option that avoids SMTP AUTH entirely; see the end of this post.
Step 1: Configure Entra ID and Exchange Online
1.1 Register the app
- Open the Microsoft Entra admin center and go to Identity > Applications > App registrations.
- Click New registration, enter a name, and keep the default single-tenant account type. Leave the redirect URI empty: the client credentials flow doesn’t use one.
- Copy the Application (client) ID and the Directory (tenant) ID.
1.2 Add the SMTP permission
- In your app, open API permissions > Add a permission.
- Choose the APIs my organization uses tab and search for Office 365 Exchange Online.
- Select Application permissions and add
SMTP.SendAsApp.
1.3 Grant admin consent
Click Grant admin consent for <your organization>. Application permissions only take effect after an administrator consents.
1.4 Create a client secret
Under Certificates & secrets, create a new client secret and copy its value immediately; it isn’t shown again. Note its expiry date, because your application stops sending mail when the secret expires.
For production, prefer a certificate over a client secret, and never hardcode credentials in source code. Read them from a protected configuration file or from environment variables.
1.5 Register the service principal in Exchange and grant mailbox access
Entra ID permissions alone aren’t enough: Exchange Online must know the app and be told which mailbox it may use. [VERIFY]
Install-Module -Name ExchangeOnlineManagement
Connect-ExchangeOnline -Organization <TENANT_ID>
# Use the Application (client) ID from the app registration, and the
# Object ID of the *Enterprise application* (not of the app registration).
New-ServicePrincipal -AppId <APPLICATION_ID> -ObjectId <ENTERPRISE_APP_OBJECT_ID>
# Allow the app to use the sending mailbox
Add-MailboxPermission -Identity "info@example.com" `
-User <ENTERPRISE_APP_OBJECT_ID> -AccessRights FullAccess
1.6 Enable SMTP AUTH for the mailbox
SMTP AUTH is disabled by default in some tenants. Enable it for the sending mailbox:
Set-CASMailbox -Identity "info@example.com" -SmtpClientAuthenticationDisabled $false
You can also check the setting in the Microsoft 365 admin center under the mailbox’s Manage email apps. If SMTP AUTH is disabled at tenant level, a tenant administrator must change that first.
Step 2: Request an access token with TIdHTTP
Send a form-encoded POST to the tenant’s token endpoint with the client credentials. For SMTP, the scope is https://outlook.office365.com/.default.
uses
Classes, SysUtils, IdHTTP, IdSSLOpenSSL, System.JSON; // FPC: use fpjson/jsonparser
function GetAccessToken(const TenantID, ClientID, ClientSecret: string;
out ExpiresInSeconds: Integer): string;
var
HTTP: TIdHTTP;
SSL: TIdSSLIOHandlerSocketOpenSSL;
Params: TStringList;
Response: string;
Json: TJSONObject;
begin
HTTP := TIdHTTP.Create(nil);
SSL := TIdSSLIOHandlerSocketOpenSSL.Create(HTTP);
Params := TStringList.Create;
try
SSL.SSLOptions.SSLVersions := [sslvTLSv1_2];
HTTP.IOHandler := SSL;
Params.Add('grant_type=client_credentials');
Params.Add('client_id=' + ClientID);
Params.Add('client_secret=' + ClientSecret);
Params.Add('scope=https://outlook.office365.com/.default');
// TIdHTTP.Post(URL, TStrings) sends application/x-www-form-urlencoded
Response := HTTP.Post(
'https://login.microsoftonline.com/' + TenantID + '/oauth2/v2.0/token',
Params);
Json := TJSONObject.ParseJSONValue(Response) as TJSONObject;
try
Result := Json.GetValue<string>('access_token');
ExpiresInSeconds := Json.GetValue<Integer>('expires_in');
finally
Json.Free;
end;
finally
Params.Free;
HTTP.Free; // also frees SSL (owned by HTTP)
end;
end;
Notes:
- The access token is valid for about an hour (see
expires_in). Cache it and request a new one shortly before it expires; don’t request a token for every message. - On failure the endpoint returns an HTTP 4xx status with a JSON
erroranderror_description.TIdHTTPraisesEIdHTTPProtocolException; readE.ErrorMessageto see Microsoft’s explanation.
Step 3: Send the message with TIdSMTP
Connect to smtp.office365.com on port 587 with explicit TLS and authenticate with the XOAUTH2 SASL mechanism. The user name is the sending mailbox and the password is the raw access token: don’t Base64-encode it yourself.
procedure SendMail(const Mailbox, AccessToken: string);
var
SMTP: TIdSMTP;
SSL: TIdSSLIOHandlerSocketOpenSSL;
UserPass: TIdUserPassProvider;
SASL: TIdSASLListEntry;
Msg: TIdMessage;
begin
SMTP := TIdSMTP.Create(nil);
SSL := TIdSSLIOHandlerSocketOpenSSL.Create(SMTP);
Msg := TIdMessage.Create(nil);
try
SSL.SSLOptions.SSLVersions := [sslvTLSv1_2];
UserPass := TIdUserPassProvider.Create(SMTP);
UserPass.Username := Mailbox; // the mailbox granted in step 1.5
UserPass.Password := AccessToken; // raw access token from step 2
SASL := SMTP.SASLMechanisms.Add;
SASL.SASL := TIdSASLXOAuth2.Create(SMTP); // [VERIFY] class and unit name
TIdSASLXOAuth2(SASL.SASL).UserPassProvider := UserPass;
SMTP.IOHandler := SSL;
SMTP.Host := 'smtp.office365.com';
SMTP.Port := 587;
SMTP.UseTLS := utUseExplicitTLS;
SMTP.AuthType := satSASL;
Msg.From.Address := Mailbox; // must match the authorized mailbox
Msg.Recipients.EMailAddresses := 'recipient@example.com';
Msg.Subject := 'Hello from Delphi';
Msg.Body.Text := 'Sent with OAuth2 and Indy.';
SMTP.Connect;
try
SMTP.Authenticate;
SMTP.Send(Msg);
finally
SMTP.Disconnect;
end;
finally
Msg.Free;
SMTP.Free; // also frees SSL and the SASL objects it owns
end;
end;
Putting it together:
var
Token: string;
ExpiresIn: Integer;
begin
Token := GetAccessToken(TenantID, ClientID, ClientSecret, ExpiresIn);
SendMail('info@example.com', Token);
end;
Troubleshooting
| Symptom | Likely cause | What to check |
|---|---|---|
535 5.7.3 Authentication unsuccessful | Service principal or mailbox permission missing, or SMTP AUTH disabled | Steps 1.5 and 1.6; wait a few minutes after changes |
Token request returns invalid_client | Wrong or expired client secret | Secret value (not ID) and expiry date |
Token request returns invalid_scope | Wrong scope for the flow | Use https://outlook.office365.com/.default with client credentials |
530 5.7.57 Client not authenticated or similar | Sending mailbox differs from the authorized one | From address and UserPass.Username must match the mailbox |
| TLS or handshake errors | Missing or wrong OpenSSL DLLs | 64-bit DLLs matching your Indy version, TLS 1.2 enabled |
Alternative: Microsoft Graph sendMail
If you don’t need SMTP specifically, the Microsoft Graph sendMail endpoint sends mail over HTTPS with the same kind of access token (scope https://graph.microsoft.com/.default and the Mail.Send application permission). It doesn’t use SMTP AUTH at all, so you can skip steps 1.5 and 1.6, and it works with plain TIdHTTP. For new projects it is often the simpler choice.
Conclusion
With a registered Entra ID app, an Exchange service principal with mailbox access, an access token from TIdHTTP, and the XOAUTH2 SASL mechanism in TIdSMTP, your Object Pascal application can send mail through Exchange Online without a password. Next steps worth adding in your own code: token caching and refresh, certificate-based credentials, and the delegated flow with refresh tokens if you need to send as a user.
Source code
A working project is available on GitHub: delphi-smtp-with-oauth2. It was created with Lazarus 4 / Free Pascal 3.2.2 in Delphi compatibility mode, uses Indy 10.6.3.10, and requires the 64-bit OpenSSL DLLs from the Indy OpenSSL-Binaries repository.