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:

  1. Register an Entra ID app and configure Exchange Online.
  2. Request an access token with TIdHTTP.
  3. Send the message with TIdSMTP using 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, and offline_access for refresh tokens). The two flows use different permissions and scopes, so don’t mix them. The Microsoft Graph sendMail API 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

  1. Open the Microsoft Entra admin center and go to Identity > Applications > App registrations.
  2. 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.
  3. Copy the Application (client) ID and the Directory (tenant) ID.

1.2 Add the SMTP permission

  1. In your app, open API permissions > Add a permission.
  2. Choose the APIs my organization uses tab and search for Office 365 Exchange Online.
  3. 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 &lt;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 &lt;APPLICATION_ID> -ObjectId &lt;ENTERPRISE_APP_OBJECT_ID>

# Allow the app to use the sending mailbox
Add-MailboxPermission -Identity "info@example.com" `
    -User &lt;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&lt;string>('access_token');
      ExpiresInSeconds := Json.GetValue&lt;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 error and error_description. TIdHTTP raises EIdHTTPProtocolException; read E.ErrorMessage to 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

SymptomLikely causeWhat to check
535 5.7.3 Authentication unsuccessfulService principal or mailbox permission missing, or SMTP AUTH disabledSteps 1.5 and 1.6; wait a few minutes after changes
Token request returns invalid_clientWrong or expired client secretSecret value (not ID) and expiry date
Token request returns invalid_scopeWrong scope for the flowUse https://outlook.office365.com/.default with client credentials
530 5.7.57 Client not authenticated or similarSending mailbox differs from the authorized oneFrom address and UserPass.Username must match the mailbox
TLS or handshake errorsMissing or wrong OpenSSL DLLs64-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.

Related posts