Home / Guides / Check SPF, DKIM and DMARC before go-live

Check SPF, DKIM and DMARC on your app's emails before go-live

Your app is about to send password resets, sign-up confirmations and invoices to real people. If the sending service is not allowed to send for your domain, or the mail is not signed for it, those emails land in junk or are rejected outright, and nobody tells you. This guide shows how to check all three records with PowerShell, how to read the verdict a real mail server gives your test message, and the failures that turn up most often.

Short answer.

Look up the records first: the SPF record is a TXT record on your domain that starts with v=spf1, the DMARC record is a TXT record on _dmarc. plus your domain, and each DKIM key is a TXT record on <selector>._domainkey. plus your domain. Then send a real email from the app to a mailbox you control and read its Authentication-Results header: you want spf=pass, dkim=pass and, above all, dmarc=pass. DMARC passes only when SPF or DKIM passes for the domain in the From address.

What the three checks do

  • SPF (RFC 7208) lists the servers allowed to send for a domain. The receiver checks the domain of the envelope sender (the MAIL FROM, shown in the Return-Path header), not the From address the reader sees.
  • DKIM (RFC 6376) is a signature over the message. The signing domain is the d= tag of the DKIM-Signature header and the key is looked up with its s= tag, the selector.
  • DMARC (RFC 9989, which replaced RFC 7489 in 2026) ties the two to the From address. It passes when SPF passes for a domain that aligns with the From domain, or a valid DKIM signature does. Relaxed alignment, the default, accepts the same organizational domain (mail.contoso.com aligns with contoso.com); adkim=s or aspf=s require an exact match. The record's p= tells receivers what to do with mail that fails.

Step 1: look up the records with PowerShell

Resolve-DnsName -Name contoso.com -Type TXT returns every TXT record on a name. The script below does the lookups for you: it finds the SPF record, counts the DNS lookups it costs (the limit is 10), finds the DMARC record (walking up to the parent domain if the From domain is a subdomain without its own record, as receivers do) and checks the DKIM keys for the selectors you name. It only reads DNS and changes nothing. It runs in Windows PowerShell 5.1, which comes with Windows.

# Get-MailAuthRecords.ps1: shows a domain's SPF, DMARC and DKIM records and counts the SPF DNS lookups.
# Runs in Windows PowerShell 5.1 (Resolve-DnsName is in the DnsClient module that comes with Windows).
param(
    [Parameter(Mandatory)][string]$Domain,      # the domain in your app's From address
    [string[]]$DkimSelector = @(),              # s= value from a DKIM-Signature header, e.g. selector1
    [string]$Server                             # optional DNS server, e.g. 1.1.1.1, to see what the internet sees
)

$dns = @{ DnsOnly = $true; ErrorAction = 'SilentlyContinue' }
if ($Server) { $dns.Server = $Server }

function Get-Txt([string]$Name) {
    # A long TXT record arrives in 255-character pieces: join them back into one string.
    Resolve-DnsName -Name $Name -Type TXT @dns |
        Where-Object { $_.Section -eq 'Answer' -and $_.Type -eq 'TXT' } |
        ForEach-Object { $_.Strings -join '' }
}

$script:lookups = 0
function Measure-Spf([string]$Name, [int]$Depth) {
    # RFC 7208 4.6.4: include, a, mx, ptr, exists and redirect each cost one DNS lookup; at most 10 in total.
    $records = @(Get-Txt $Name | Where-Object { $_ -match '^v=spf1(\s|$)' })
    if ($records.Count -ne 1) { "  ! $Name has $($records.Count) SPF records (must be exactly one): permerror"; return }
    foreach ($term in ($records[0] -split '\s+' | Select-Object -Skip 1)) {
        $mech = $term.TrimStart('+', '-', '~', '?')
        if ($mech -match '^(include:|redirect=)(.+)$') {
            $script:lookups++
            if ($Depth -lt 10) { Measure-Spf $Matches[2] ($Depth + 1) }
        }
        elseif ($mech -match '^(a|mx|ptr|exists)([:/]|$)') { $script:lookups++ }
    }
}

"Domain : $Domain"

# SPF: exactly one TXT record that starts with v=spf1
$spf = @(Get-Txt $Domain | Where-Object { $_ -match '^v=spf1(\s|$)' })
switch ($spf.Count) {
    0 { 'SPF    : none (no v=spf1 TXT record)' }
    1 {
        "SPF    : $($spf[0])"
        Measure-Spf $Domain 0
        $verdict = if ($script:lookups -gt 10) { 'TOO MANY: receivers return permerror' } else { 'ok' }
        "         DNS lookups: $($script:lookups) of 10 ($verdict)"
    }
    default { "SPF    : $($spf.Count) records: receivers return permerror; merge them into one" }
}

# DMARC: _dmarc.<domain>; if there is none, receivers use the record of the organizational (parent) domain
$name = $Domain; $dmarc = $null
while (-not $dmarc -and $name.Split('.').Count -ge 2) {
    $dmarc = Get-Txt "_dmarc.$name" | Where-Object { $_ -match '^v=DMARC1' } | Select-Object -First 1
    if (-not $dmarc) { $name = $name.Substring($name.IndexOf('.') + 1) }
}
if ($dmarc) {
    $tags = @{}
    foreach ($pair in $dmarc -split ';') {
        $k, $v = $pair.Split('=', 2)
        if ($v) { $tags[$k.Trim()] = $v.Trim() }
    }
    $from = if ($name -ne $Domain) { " (found on _dmarc.${name}: sp= applies to $Domain if set)" } else { '' }
    "DMARC  : $dmarc$from"
    '         policy p={0}  sp={1}  adkim={2}  aspf={3}  reports rua={4}' -f $tags['p'],
        $(if ($tags['sp']) { $tags['sp'] } else { '(= p)' }),
        $(if ($tags['adkim']) { $tags['adkim'] } else { 'r' }),
        $(if ($tags['aspf']) { $tags['aspf'] } else { 'r' }),
        $(if ($tags['rua']) { $tags['rua'] } else { '(none)' })
}
else { 'DMARC  : none (no v=DMARC1 record on _dmarc.<domain> or its parents)' }

# DKIM: <selector>._domainkey.<domain>; the selector is the s= tag of the DKIM-Signature header
foreach ($s in $DkimSelector) {
    $key = Get-Txt "$s._domainkey.$Domain" | Where-Object { $_ -match '(^|;)\s*p=' } | Select-Object -First 1
    if (-not $key) { "DKIM   : $s : no key published at $s._domainkey.$Domain" }
    elseif ($key -match '(^|;)\s*p=\s*(;|$)') { "DKIM   : $s : key REVOKED (empty p=)" }
    else { "DKIM   : $s : key published ($($key.Length) characters)" }
}
if (-not $DkimSelector) { 'DKIM   : pass -DkimSelector <s= value from a DKIM-Signature header> to check a key' }

Use the domain of your app's From address. The selector is the s= value in the DKIM-Signature header of a message the app has sent; your sending service's setup page names it too. Add -Server 1.1.1.1 (or any public resolver) if your company DNS has its own internal copy of the zone, because receivers see the public one.

.\Get-MailAuthRecords.ps1 -Domain contoso.com -DkimSelector selector1, selector2
.\Get-MailAuthRecords.ps1 -Domain mail.contoso.com -DkimSelector s1 -Server 1.1.1.1

On a machine without PowerShell, nslookup -type=TXT _dmarc.contoso.com shows the same records, without the lookup count.

Step 2: send a real email and read Authentication-Results

Records can be right and the mail still fail, because what counts is the path a real message takes. Trigger the email in the app (a password reset is a good one), send it to a mailbox you own at a big provider, and open its headers: in Outlook choose File > Properties and look in Internet headers; in Outlook on the web choose ... > View > View message details; in Gmail choose Show original. The receiving server records its verdicts in the Authentication-Results header (RFC 8601). Save the headers to a text file, or save the whole message as .eml, and this script picks the verdicts out:

# Read-AuthResults.ps1: prints the SPF, DKIM and DMARC verdicts from a saved message (.eml) or a file of pasted headers.
param([Parameter(Mandatory)][string]$Path)

$text = [System.IO.File]::ReadAllText((Resolve-Path $Path))
$head = ($text -split "\r?\n\r?\n", 2)[0]          # the header block ends at the first empty line
$head = $head -replace "\r?\n[ \t]+", ' '         # unfold headers that continue on the next line

$headers = $head -split "\r?\n"
$from = ($headers | Where-Object { $_ -match '^From:' } | Select-Object -First 1) -replace '^From:\s*', ''
$retPath = ($headers | Where-Object { $_ -match '^Return-Path:' } | Select-Object -First 1) -replace '^Return-Path:\s*', ''
"From        : $from"
"Return-Path : $retPath"

# The top-most Authentication-Results header was added by the last server, normally your own mailbox provider.
$i = 0
foreach ($ar in $headers | Where-Object { $_ -match '^Authentication-Results:' }) {
    $i++
    $value = $ar -replace '^Authentication-Results:\s*', ''
    $authserv = ($value -split ';', 2)[0].Trim()
    if ($authserv -match '=') { $authserv = '(not named in the header)' }
    ''
    "Authentication-Results #$i, added by: $authserv"
    foreach ($m in [regex]::Matches($value, '\b(spf|dkim|dmarc|arc|compauth)=(\w+)')) {
        $method = $m.Groups[1].Value
        # the properties that follow this result, up to the next ';'
        $rest = $value.Substring($m.Index + $m.Length).Split(';')[0]
        $props = [regex]::Matches($rest, '\b(smtp\.mailfrom|smtp\.helo|header\.from|header\.d|header\.i|header\.s|policy\.\w+|action|reason)=("[^"]*"|[^\s;]+)') |
            ForEach-Object { "$($_.Groups[1].Value)=$($_.Groups[2].Value)" }
        '  {0,-9}{1,-10}{2}' -f $method, $m.Groups[2].Value, ($props -join '  ')
    }
}
if ($i -eq 0) { 'No Authentication-Results header: the message was not checked by the receiving server, or the file holds only the body.' }

Here is its output for the example in Microsoft's DMARC troubleshooting article: SPF and DKIM both pass, but for adatum.com (the sending service), while the reader sees contoso.com. Nothing aligns, so DMARC fails.

PS> .\Read-AuthResults.ps1 -Path .\password-reset.eml
From        : "Contoso" <noreply@contoso.com>
Return-Path : <x@bounces.adatum.com>

Authentication-Results #1, added by: (not named in the header)
  spf      pass      smtp.mailfrom=bounces.adatum.com
  dkim     pass      header.d=adatum.com
  dmarc    fail      action=oreject  header.from=contoso.com
  compauth fail      reason=000

What the values mean:

  • pass: the check succeeded. For SPF and DKIM, also check that the domain next to it (smtp.mailfrom, header.d) is yours.
  • fail or softfail (SPF): the sending server's IP address is not in the SPF record (-all gives fail, ~all softfail).
  • none: there is nothing to check: no SPF or DMARC record, or (for DKIM) no signature.
  • permerror: the record is broken, for example two SPF records or more than 10 lookups. temperror is a DNS timeout; try again.
  • Microsoft 365 adds compauth, its own composite verdict, and may show dmarc=bestguesspass, which means the domain has no DMARC record but would have passed with one.

A message can carry several Authentication-Results headers. Trust the top one, added by your own mailbox provider; lower ones were added by servers along the way and anyone can write one into a message.

The failures you will most likely find

  • The sending service is not in SPF. The app sends through a new email service, but the domain's SPF record only lists your office mail. Add the include: the service documents, and keep one SPF record: two records on the same name are a permerror.
  • Too many DNS lookups. Every include, a, mx, ptr, exists and redirect costs one, nested includes count too, and more than 10 is a permerror. ip4: and ip6: cost nothing. Microsoft suggests moving other senders to their own subdomain, which has its own budget.
  • DKIM not set up for the third-party sender. The service signs with its own domain (header.d is the service), so DKIM passes but does not align. Set up its custom DKIM for your domain and publish the selector records it gives you.
  • The From domain is not aligned. SPF passes for the service's bounce domain and DKIM is the service's own. Either let the service use a custom MAIL FROM (bounce) domain under yours, or sign with your domain. Microsoft's DMARC troubleshooting walks through exactly this.
  • Staging sends differently. Test and staging often send through another service, an SMTP relay or a made-up From domain. Check the environment you are about to go live with, with the From address it will really use.

Where the manual check stops

  • One message at a time. Each check means sending a mail, finding it, opening the headers and reading them.
  • Per email type and per environment. A password reset may go through one service and invoices through another, and staging may differ from production. Each combination needs its own test.
  • It is a snapshot. A DNS edit, a new sending service or a changed From address breaks it again, and nothing tells you.
  • Hard to automate. Reading the header in a test means signing in to a real mailbox over IMAP or Microsoft Graph, and a local mail catcher your app hands mail to directly never sees the real sending path, so it cannot tell you whether SPF and DMARC pass.

Questions

Why does DMARC fail when SPF and DKIM both pass?

Because neither passed for the domain in the From address. DMARC needs SPF to pass for an envelope sender (MAIL FROM) domain that aligns with the From domain, or a valid DKIM signature whose d= domain aligns with it. A sending service that uses its own bounce domain and signs with its own domain passes both checks for itself, not for you. Set up a custom MAIL FROM domain or custom DKIM signing for your domain at the service.

What does dmarc=bestguesspass mean?

Microsoft 365 writes it when the From domain has no DMARC record, but the message would have passed DMARC if it had one. Publish a DMARC record on _dmarc. so receivers apply your policy instead of guessing.

How many DNS lookups can an SPF record use?

Ten. The include, a, mx, ptr, exists and redirect terms each cost one, and the terms inside every included record count as well. ip4 and ip6 cost nothing. Above 10 the result is permerror, and receivers may reject the mail. Moving other senders to their own subdomain gives each its own budget of 10.

Can I check SPF and DMARC with a local test mail server?

Only partly. A local mail catcher that your app hands mail to directly does not see the path a real message takes from your sending service, so SPF and DMARC cannot be judged there. Send the test through your real sending service to a mailbox whose server checks them, and read the Authentication-Results header or the verdicts it reports.

Does MailPier Inbox check SPF, DKIM and DMARC?

Yes. MailPier's mail server checks SPF, DKIM and DMARC when each message arrives at your MailPier test address, and the result is part of every message's email check report: on the message page, as a badge in the inbox, and in the REST API's message JSON. Send the test mail through your real sending service so the check sees the real path.

Related

Sources: Resolve-DnsName, Anti-spam message headers: Authentication-Results, Set up SPF, Set up DMARC, Troubleshoot email authentication in Microsoft 365 (Microsoft Learn); RFC 7208 (SPF), RFC 6376 (DKIM), RFC 9989 (DMARC), RFC 8601 (Authentication-Results).

An unhandled error has occurred. 🗙