[BP 2.1.1] Build Sheet: Mail Authentication to Enforcement

Taking a domain from no email authentication at all to a rejecting DMARC policy, on a worked estate, then going one layer further and authenticating the transport as well as the message. Includes the negative test that most implementations skip and the only one that proves anything.


This is the build. It takes a domain from no email authentication at all to a rejecting DMARC policy, then goes one layer further than most baselines reach and authenticates the transport as well as the message. It is written to be followed from the text alone on a worked estate, and every command here is one you run rather than one that illustrates a point.

The estate is catsnackjack.com, a tenant whose initial domain is catsnackjack.onmicrosoft.com, sending its own mail through Exchange Online, with a marketing platform sending from news.catsnackjack.com and a payroll application that relays through a third party nobody documented. That last one is not colour. Every estate has one, and finding it is the reason step 4 exists.

Two conventions I use throughout and recommend adopting. Aggregate reports go to a dedicated address on a monitored mailbox rather than to a person, named [email protected], because the volume is real and the destination outlives whoever set it up. And every subdomain that sends gets its own DMARC record rather than inheriting the parent’s, because inheritance means you cannot move one sender to enforcement without moving all of them.


Before you start

You need write access to the DNS zone for every domain in scope, and the ability to connect to Exchange Online with a role that can read and set organisation configuration. Confirm both before you begin, because discovering halfway through that the zone is managed by a marketing agency is the most common reason this build stalls for two weeks.

Install-Module ExchangeOnlineManagement -MinimumVersion 3.5.0 -Scope CurrentUser
$ErrorActionPreference = 'Stop'
Connect-ExchangeOnline -ShowBanner:$false

# Assert you are in the tenant you think you are in, before any write
Get-OrganizationConfig | Format-List Name,DisplayName
Get-AcceptedDomain | Format-Table DomainName,DomainType,Default

Step 1: inventory what sends for the domain

Nothing else in this build is safe until you have this list, and you will not get it complete on the first pass. Start from what the organisation knows: the mail platform itself, any marketing or transactional sending service, the finance and HR applications, the ticketing system, the monitoring stack, and anything with a printer in front of it. Write it down with the domain each one sends as, because a sender using a subdomain is a different problem from one using the parent.

Read the existing record, if there is one, as evidence rather than as truth. It tells you what somebody once believed was sending.

Resolve-DnsName -Name catsnackjack.com -Type TXT |
  Where-Object { $_.Strings -match '^v=spf1' } | Select-Object -ExpandProperty Strings

Resolve-DnsName -Name _dmarc.catsnackjack.com -Type TXT -ErrorAction SilentlyContinue |
  Select-Object -ExpandProperty Strings

Step 2: publish SPF

One record per sending domain. For the worked estate the parent sends only through Exchange Online, and the marketing subdomain sends only through its platform.

NameTypeValueWhy
catsnackjack.comTXTv=spf1 include:spf.protection.outlook.com -allExchange Online is the only sender; hard fail so DMARC can act when DKIM is also absent
news.catsnackjack.comTXTv=spf1 include:<platform-include> -allSeparate record so the subdomain reaches enforcement on its own schedule

Count your lookups before publishing. The mechanisms that count are include, a, mx, exists and redirect; addresses and all do not. Ten is the ceiling and exceeding it makes the record fail rather than truncate. If you are near it, the correct fix is fewer senders or a dedicated subdomain per sender, not flattening. Never expand spf.protection.outlook.com to literal addresses; Microsoft rotates them and your record will silently become wrong.

Counting what you can see does not prove anything, because every include expands into whatever the other party publishes, and Microsoft’s own include is not a leaf. The count that matters is the recursive one. This walks the tree and prints it, so that when the number is wrong you can see which sender spent the budget.

function Get-SpfTree {
    param(
        [Parameter(Mandatory)][string]$Domain,
        [int]$Depth = 0,
        [System.Collections.ArrayList]$Result
    )
    if (-not $Result) { $Result = [System.Collections.ArrayList]::new() }
    if ($Depth -gt 10) {
        [void]$Result.Add([pscustomobject]@{ Depth=$Depth; Term='RECURSION CEILING'; Target=$Domain; Counts=$false })
        return $Result
    }

    $txt = @((Resolve-DnsName $Domain -Type TXT -ErrorAction SilentlyContinue).Strings |
             Where-Object { $_ -match '^v=spf1' })

    if ($txt.Count -eq 0) {
        [void]$Result.Add([pscustomobject]@{ Depth=$Depth; Term='VOID (no SPF record)'; Target=$Domain; Counts=$false })
        return $Result
    }
    if ($txt.Count -gt 1) {
        [void]$Result.Add([pscustomobject]@{ Depth=$Depth; Term='PERMERROR (multiple SPF records)'; Target=$Domain; Counts=$false })
    }

    foreach ($term in ($txt[0] -split '\s+' | Where-Object { $_ -and $_ -ne 'v=spf1' })) {
        $bare = $term -replace '^[+~?-]', ''
        switch -Regex ($bare) {
            '^include:(.+)$' {
                [void]$Result.Add([pscustomobject]@{ Depth=$Depth; Term=$bare; Target=$Matches[1]; Counts=$true })
                $null = Get-SpfTree -Domain $Matches[1] -Depth ($Depth + 1) -Result $Result
            }
            '^redirect=(.+)$' {
                [void]$Result.Add([pscustomobject]@{ Depth=$Depth; Term=$bare; Target=$Matches[1]; Counts=$true })
                $null = Get-SpfTree -Domain $Matches[1] -Depth ($Depth + 1) -Result $Result
            }
            '^(a|mx)$'      { [void]$Result.Add([pscustomobject]@{ Depth=$Depth; Term=$bare; Target=$Domain;     Counts=$true }) }
            '^(a|mx):(.+)$' { [void]$Result.Add([pscustomobject]@{ Depth=$Depth; Term=$bare; Target=$Matches[2]; Counts=$true }) }
            '^exists:(.+)$' { [void]$Result.Add([pscustomobject]@{ Depth=$Depth; Term=$bare; Target=$Matches[1]; Counts=$true }) }
            '^ptr'          { [void]$Result.Add([pscustomobject]@{ Depth=$Depth; Term="$bare (deprecated)"; Target=$Domain; Counts=$true }) }
        }
    }
    return $Result
}

$tree = Get-SpfTree -Domain catsnackjack.com
$tree | ForEach-Object { '{0}{1}' -f ('  ' * $_.Depth), $_.Term }

'Evaluated DNS lookups: {0} of 10' -f @($tree | Where-Object Counts).Count
'Void lookups:          {0} of 2'  -f @($tree | Where-Object { $_.Term -like 'VOID*' }).Count

What this does not do, so that you do not over-trust it: it does not expand macros, it does not evaluate exp=, it does not confirm that an a or mx target actually resolves, and it counts a ptr term while flagging it, because ptr should not be in a record written this decade. For a record built from includes and Microsoft’s own send path, which is nearly all of them, the count it returns is the count a receiver will reach.


Step 3: enable DKIM, reading the values rather than composing them

This is the step where a build guide written before mid-2025 will waste your afternoon. The target format for the selector records changed, the new form contains a partition character Microsoft assigns per domain, and the two formats cannot coexist for one selector. So the first action is to ask the service what your records should be.

$Domain = 'catsnackjack.com'

# Branch. Most estates already have a configuration, and New- is not the cmdlet for those.
$dkim = Get-DkimSigningConfig -Identity $Domain -ErrorAction SilentlyContinue

if (-not $dkim) {
    New-DkimSigningConfig -DomainName $Domain -KeySize 2048 -Enabled $false
}

# Read the exact CNAME targets. Publish these verbatim. Do not construct them.
# KeySize is an input parameter, not an output property. The sizes are per selector.
Get-DkimSigningConfig -Identity $Domain |
  Format-List Name,Enabled,Status,Selector1CNAME,Selector1KeySize,Selector2CNAME,Selector2KeySize,KeyCreationTime,RotateOnDate

Publish the two records, wait for them to resolve, then enable signing. If your DNS provider proxies records, that proxying must be off for these two or validation never completes.

Resolve-DnsName -Name selector1._domainkey.catsnackjack.com -Type CNAME
Resolve-DnsName -Name selector2._domainkey.catsnackjack.com -Type CNAME

Set-DkimSigningConfig -Identity catsnackjack.com -Enabled $true
Get-DkimSigningConfig -Identity catsnackjack.com | Format-List Name,Enabled,Status

A domain that is already signing is a different job, and the branch above will have told you so. The default key size has been 1024 for years and a great many estates are still on it. You cannot change the size in place; there is no KeySize on Set-DkimSigningConfig. You rotate, and you rotate twice, because the first rotation upgrades only the selector that becomes active next and leaves the other one where it was. Each rotation takes 96 hours and a second cannot start while the first is running, so plan eight days and check the selector sizes between them rather than assuming.

# Only for a domain that already has a configuration. Requires Status Valid or CnameMissing,
# and both selector CNAMEs present, or the rotation fails.
Rotate-DkimSigningConfig -Identity $Domain -KeySize 2048

# 96 hours later. Read the sizes, do not assume them.
Get-DkimSigningConfig -Identity $Domain |
  Format-List Name,Selector1KeySize,Selector2KeySize,KeyCreationTime,RotateOnDate,SelectorBeforeRotateOnDate,SelectorAfterRotateOnDate

# Run the second rotation only if one selector still reports 1024.
Rotate-DkimSigningConfig -Identity $Domain -KeySize 2048

Do the same for any third-party platform sending as one of your domains, using whatever selector that vendor issues. A marketing platform that cannot sign as your domain is a platform that will never pass alignment, and that is a procurement finding rather than a configuration one.


Step 4: publish DMARC at none, and actually read the reports

Publish a monitoring record and leave it long enough to learn something. Two full weeks is the minimum and a month is better, because monthly billing runs and quarterly reporting cycles produce senders that a fortnight will not reveal.

NameTypeValueWhy
_dmarc.catsnackjack.comTXTv=DMARC1; p=none; rua=mailto:[email protected]Monitoring only, with a destination that outlives the person who set it
_dmarc.news.catsnackjack.comTXTv=DMARC1; p=none; rua=mailto:[email protected]Own record so the subdomain does not inherit the parent’s policy

Do not add a failure-report address expecting forensic data from Microsoft. Microsoft does not send them, so it buys you nothing from the receiver you care most about.

What you are looking for in the reports is a sending source you cannot account for that is passing neither SPF nor DKIM. That is either an attacker or, far more often, the payroll relay from the opening paragraph. Resolve each one before moving on: bring it inside SPF, get it signing, or stop it sending as you.


Step 5: move to quarantine, then to reject

Move the low-volume subdomain first and the parent domain last, so that the blast radius of a mistake is proportional to how much you have learned. Use the percentage tag on the parent if the estate is large enough that a bad surprise matters.

StageRecord valueHold for
Ramped quarantinev=DMARC1; p=quarantine; pct=25; rua=mailto:[email protected]One reporting cycle
Full quarantinev=DMARC1; p=quarantine; rua=mailto:[email protected]Two reporting cycles
Enforcementv=DMARC1; p=reject; rua=mailto:[email protected]The finish line

One consequence to accept deliberately before you publish the last one. Once your policy is quarantine or reject, your own outbound mail that fails DMARC at the destination is routed through the high-risk delivery pool, and there is no override. That is the correct behaviour and it is also a reason to have finished step 4 properly.


Step 6: the domain Microsoft gave you

Your onmicrosoft.com domain already has SPF and is already DKIM-signed by Microsoft. That makes it a live, pre-authenticated sending identity in every tenant, and almost nobody publishes a DMARC record for it. If you do not use it for mail, park it explicitly at enforcement. If you do use it, treat it as a real sending domain and take it through the same ramp.

SettingValueWhy
DMARC for the initial domainv=DMARC1; p=reject; rua=mailto:[email protected]Closes an authenticated identity nobody is monitoring

Step 7: trusted ARC sealers, only where something genuinely breaks

If a legitimate intermediary modifies messages in transit, a mailing list, a disclaimer service, a scanning gateway, its changes break the original authentication and legitimate mail starts failing DMARC. Trusting that intermediary’s ARC seal tells Exchange to honour the authentication result recorded before the modification.

Add sealers only when you have observed that failure, not preemptively. Trusting a sealer is a delegation of authentication authority to a third party, and a compromised vendor on that list passes spoofed mail through your checks. Note also that the parameter replaces the entire list rather than appending to it, so a single-vendor command silently removes every other entry. Mail relayed between Microsoft 365 tenants is trusted automatically and needs nothing here.

# Read the current list FIRST. This parameter replaces, it does not append.
Get-ArcConfig | Format-List Identity,ArcTrustedSealers

Set-ArcConfig -Identity Default -ArcTrustedSealers "existingvendor.com,newvendor.com"

Step 8: authenticate the channel, not just the message

Everything so far proves who wrote the message. This step proves you are talking to the right server over a connection nobody has downgraded. Outbound, Exchange Online already does this and there is no tenant opt-out, so the work here is inbound and it is opt-in per domain.

Understand before you begin that this rewrites the MX record of a live domain, and that the two enablement commands are not the first things you run. They bracket a DNS cutover. Enabling DNSSEC returns a new mail exchanger target which you publish at low preference, prove independently, promote, and only then cut over to, and DANE itself is switched on after the old record is gone. Your onmicrosoft.com domain is not supported and neither are self-service domains. Read the rollback at the end of this step before you run the first command.

The returned target looks like catsnackjack-com.o-v1.mx.microsoft, and the segment in the middle is a partition Microsoft assigns per domain exactly as it does for the DKIM selectors. Read it from the command output. Do not compose it from that example. The status command returns the same value as ExpectedMxRecord, so you have two ways to read it and no reason to type it.

OrderDo thisWhy it is in this position
1Lower the TTL on the existing MX record to the minimum your provider allows, then wait out the previous TTLEverything after this depends on being able to change MX records quickly, including the way back
2If you publish MTA-STS, set the policy mode to testing, update the policy ID, and wait out max_ageAn enforcing MTA-STS policy naming only the old host will reject the new one
3Enable-DnssecForVerifiedDomain, and keep the DnssecMxValue it returnsThis creates the target. It does not publish it and it does not enable DANE
4Publish the returned value as a new MX at priority 20, lowest TTL. If you use MTA-STS, replace the legacy mx line in the policy file with the new host and bump the policy IDPriority 20 means nothing routes to it yet, so a mistake here costs nothing
5Prove the new exchanger with the Inbound SMTP Email test, expanding the test steps to confirm the host ending mx.microsoft was tested successfullyThis is the gate. Do not proceed on DNS resolution alone
6Legacy MX to priority 30, new MX to priority 0The cutover, with the old record still present as a fallback
7Delete the legacy record ending mail.protection.outlook.com, then raise the new record’s TTL to 3600DANE cannot be enabled while a non-DNSSEC exchanger is still published
8Enable-SmtpDaneInboundNow, and not before, because it binds to the exchanger you just made authoritative
9Confirm TLSA propagation with the DANE Validation test after 15 to 30 minutes, then return MTA-STS to enforce and update the policy IDExchange Online publishes several TLSA records and expects some to fail validation; one passing is a pass

The two commands, in their real positions. Everything between them is DNS work.

# Step 3. Returns Result / DnssecMxValue / ErrorData. Keep the value.
Enable-DnssecForVerifiedDomain -DomainName catsnackjack.com

# Read it back before you publish anything. ExpectedMxRecord is the record to publish,
# and the three validation fields name the gate you are failing rather than just failing.
Get-DnssecStatusForVerifiedDomain -DomainName catsnackjack.com |
  Format-List DnssecFeatureStatus,ExpectedMxRecord,DnsValidation,MxValidation,MtaStsValidation

# Steps 4 to 7 happen at the DNS provider, not here.

# Step 8, after the legacy MX record has been deleted.
Enable-SmtpDaneInbound -DomainName catsnackjack.com

# Its only parameter is -DomainName. It takes none of the -Skip switches.
Get-SmtpDaneInboundStatus -DomainName catsnackjack.com

One asymmetry in Microsoft’s own documentation, because it will catch you. The DNSSEC procedure puts MTA-STS into testing mode and never tells you to take it out. Only the DANE procedure does, at the end. If you enable DNSSEC and stop there, you have quietly left your own transport policy unenforced, which is the opposite of what this step is for.

The way back

Written out because a step that rewrites MX records without a stated rollback is not a build sheet. If DANE validation fails, disable DANE and leave DNSSEC alone. If the failure is in DNSSEC itself, turn it off at the provider first. If inbound mail is affected, restore the legacy exchanger before touching anything else.

Disable-SmtpDaneInbound -DomainName catsnackjack.com

# If the MX change itself is the problem: republish the legacy record at priority 20,
# confirm inbound flow with the Inbound SMTP Email test, and only then unwind the rest.
Disable-DnssecForVerifiedDomain -DomainName catsnackjack.com

# Then delete the mx.microsoft record and return the legacy record to priority 0.

Add a reporting record so failures are visible rather than silent, because a transport failure presents to your service desk as an intermittent delivery problem with a numeric code and no context.

NameTypeValueWhy
_smtp._tls.catsnackjack.comTXTv=TLSRPTv1; rua=mailto:[email protected]Turns silent transport failures into something you receive

Step 9: the end-to-end test

The test is not that the records resolve. It is that a receiver outside your organisation reaches the verdict you intended, and that a forgery does not.

Send a message from a mailbox in the tenant to an external address you control on a different provider. Open the received message’s headers and read three things: that SPF passed, that DKIM passed with your domain as the signing domain, and that DMARC passed. If DKIM shows the initial domain rather than your custom domain, signing is not enabled for the domain you think it is.

Then run the negative half, which is the half people skip and the only half that proves anything. It is two tests, not one, and they prove different things. From a host that is not in your SPF record and that cannot sign as you, send a message with your domain in the from address to a mailbox in your tenant. Both aligned SPF and aligned DKIM must fail for DMARC to fail, which in this test they will, because a stranger cannot produce your signature. That proves your own tenant honours a published policy.

Then send the same forgery to a mailbox you control at another provider. That proves the thing you actually published the record for, which is that other people act on it. The two are not interchangeable and passing the first tells you nothing about the second. Read the outcome rather than assuming it: a rejection is what a clean estate returns, but a tenant allow entry, an inbound connector or a mail flow rule can override the verdict, so a delivery is a finding to investigate rather than proof the policy failed.

One caution when reading headers as evidence. A composite authentication pass can be reached on reverse-DNS validation alone, and a best-guess pass means the sender published no DMARC record at all and the verdict was inferred. Neither is proof that SPF or DKIM passed, and both are routinely misread as clean results in incident write-ups.


The consolidated readback

Run this against every domain in scope when you believe you are finished, and keep the output. It is the evidence that the build was completed rather than started.

$ErrorActionPreference = 'Stop'
Connect-ExchangeOnline -ShowBanner:$false

$domains = @('catsnackjack.com','news.catsnackjack.com','catsnackjack.onmicrosoft.com')

foreach ($d in $domains) {
    "`n=== $d ==="
    (Resolve-DnsName $d -Type TXT).Strings | Where-Object { $_ -match '^v=spf1' }
    (Resolve-DnsName "_dmarc.$d" -Type TXT -ErrorAction SilentlyContinue).Strings |
        Where-Object { $_ -match '^v=DMARC1' }
    Get-DkimSigningConfig -Identity $d -ErrorAction SilentlyContinue |
        Format-List Name,Enabled,Status,Selector1KeySize,Selector2KeySize
}

Get-AntiPhishPolicy | Format-List Name,HonorDmarcPolicy,DmarcQuarantineAction,DmarcRejectAction
Get-ArcConfig | Format-List ArcTrustedSealers

Completion checklist

  • Every sending source for every domain is written down, including the one nobody remembered
  • One SPF record per sending domain, ending in hard fail, inside the ten-lookup limit, nothing flattened
  • DKIM enabled per custom domain, with the CNAME values read from the service and published verbatim
  • DMARC at enforcement on every domain and subdomain, aggregate reports arriving at a monitored address
  • The initial onmicrosoft.com domain has its own DMARC record
  • ARC sealers added only where an observed failure justified it, and the existing list read before it was replaced
  • A real message passes all three checks at an external receiver
  • A forged message fails aligned SPF and aligned DKIM, and the receiving tenant applies the published policy, with any override that changed the outcome identified
  • The same forgery reaches the verdict you intended at an external receiver, not only inside your own tenant

Optional transport layer

  • Inbound DANE and DNSSEC enabled where supported, with the new mail exchanger record published
  • A TLS reporting record exists so transport failures arrive rather than vanish

Best Practices
‹ Previous: [BP 2.1] The Exchange Online Baseline: The Critical Tier
Next: [BP 2.2] The Exchange Online Baseline: Recommended and Optional Tiers