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-Pathheader), not the From address the reader sees. - DKIM (RFC 6376) is a signature over the message. The signing domain is the
d=tag of theDKIM-Signatureheader and the key is looked up with itss=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.comaligns withcontoso.com);adkim=soraspf=srequire an exact match. The record'sp=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.failorsoftfail(SPF): the sending server's IP address is not in the SPF record (-allgives fail,~allsoftfail).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.temperroris a DNS timeout; try again.-
Microsoft 365 adds
compauth, its own composite verdict, and may showdmarc=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 apermerror. - Too many DNS lookups. Every
include,a,mx,ptr,existsandredirectcosts one, nested includes count too, and more than 10 is apermerror.ip4:andip6: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.dis 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.
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
- MailPier Inbox: hosted test inboxes for QA and dev teams
- Get an email when a Windows service stops
- All guides
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).