SPF, DKIM and DMARC: why a TXT query finds only SPF

Three DNS records decide whether mail claiming to come from a domain is accepted, and all three are TXT records. That is where the trouble starts. They sit at three different names, only one of them is at the name you would think to ask, and the other two cannot be found by asking a domain for its TXT records at all. So a tool that reports SPF: pass and nothing else is answering a narrower question than the one that sent you looking, which is the usual reason mail from a domain that “has SPF” still lands in Junk.

What follows is how to read all three from a Windows admin workstation, with Resolve-DnsName and with nslookup, and then five boundaries where the records behave differently from the way they are usually described: where each record lives, what the ten-lookup limit actually counts, what a long record looks like on the wire, what an SPF pass does and does not tell you about DMARC, and which of the three a subdomain inherits.


Quick answer

Three lookups, three names, one record type:

# SPF: at the domain itself
Resolve-DnsName -Name example.com -Type TXT

# DMARC: at _dmarc under the domain
Resolve-DnsName -Name _dmarc.example.com -Type TXT

# DKIM: at <selector>._domainkey under the domain, and you have to know the selector
Resolve-DnsName -Name selector1._domainkey.example.com -Type TXT

The first command returns every TXT record published at the apex, which is where domain verification strings live too, so the SPF record is one row among several. The second and third return nothing at all unless you ask for those exact names: Resolve-DnsName does not walk down to _dmarc or _domainkey for you, and nothing in the apex answer hints that they exist.

Note DNS gives you no way to list the selectors that exist under a name. A verifier gets the selector from the s= tag of the DKIM-Signature header on the message it is checking, and so do you. Prerequisite 3 below covers where to find it.

Before the first example

Four things decide whether these lookups tell you the truth.

1. The DnsClient module. Resolve-DnsName ships with Windows and is the cmdlet the rest of this article uses. Confirm it resolves before blaming DNS:

Get-Command Resolve-DnsName | Select-Object Name, ModuleName, Version

2. A resolver that will answer TXT queries. A workstation pointed at a domain controller gets whatever that server is willing to forward, and a filtering resolver can return something other than what is published. Ask a public resolver too and compare, using the documented -Server parameter:

Resolve-DnsName -Name example.com -Type TXT -Server 1.1.1.1 -DnsOnly

The fourth example on that cmdlet page uses -DnsOnly and describes it as resolving “a name using only DNS”, so LLMNR and NetBIOS queries are not issued, which keeps a local name-resolution quirk out of the answer. If the two resolvers disagree and the record was edited recently, checking propagation across public resolvers answers whether the change has reached the world yet or only your side of it.

3. The DKIM selector. It comes off a message, not out of DNS. Open the internet headers of any mail the domain sent and read the s= tag of the DKIM-Signature field. The field below is assembled from the tags RFC 6376 documents, to show where the two values you need sit; it is not a capture from a live message:

DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=example.com; s=selector1;
        h=from:to:subject:date:message-id;
        bh=2jUSOH9NhtVGCQWNr9Br...;
        b=AuUoFEfDxTDkHlLXSZEpZj79...

d=example.com is the signing domain and s=selector1 is the selector, so the name to query is selector1._domainkey.example.com. Both values are required tags, so any signed message carries them.

4. A reader that joins the record correctly, and a baseline. A TXT record arrives as a list of character-strings, and they have to be joined without spaces. One helper does that once and the rest of the article reuses it:

function Get-TxtRecord {
    param([string]$Name, [string]$Prefix)

    $answers = Resolve-DnsName -Name $Name -Type TXT -DnsOnly -ErrorAction SilentlyContinue
    foreach ($answer in @($answers)) {
        # RFC 7208 section 3.3: concatenated together without adding spaces
        $value = @($answer.Strings) -join ''
        if ($value.StartsWith($Prefix, 'OrdinalIgnoreCase')) { return $value }
    }
    return $null
}

Then take a baseline, because every change to these three records is a change somebody will later deny making. Keep the records as they came back, unparsed, so a later comparison has something exact to compare against:

$domains = 'example.com', 'marketing.example.com', 'example.net'

$before = foreach ($domain in $domains) {
    [pscustomobject]@{
        Domain = $domain
        Spf    = Get-TxtRecord -Name $domain                         -Prefix 'v=spf1'
        Dmarc  = Get-TxtRecord -Name "_dmarc.$domain"                -Prefix 'v=DMARC1'
        Dkim   = Get-TxtRecord -Name "selector1._domainkey.$domain"  -Prefix 'v=DKIM1'
    }
}
$before | Format-Table Domain, Spf -AutoSize
Careful That helper reads a property called Strings off the returned object. It is the right property, and it is also not documented anywhere on the cmdlet reference page, which is a boundary of its own further down. Run Resolve-DnsName example.com -Type TXT | Format-List * once on your own machine before trusting any script that names a property.

Example 1: read the SPF record

SPF lives at the domain itself, alongside every other TXT record anyone has ever added there. The helper picks out the one that starts with the version token and joins its strings:

Get-TxtRecord -Name example.com -Prefix 'v=spf1'

A returned string is the policy; $null means no SPF record is published, which is a different state from a record that exists and authorises nothing. Microsoft documents the second one explicitly as the record to publish on a domain that should never send mail: a parked domain gets v=spf1 -all.

Trap Reading only the first TXT record the server returns is an easy way to get this wrong. The apex of a real domain carries verification strings from every service the company has ever trialled, and the SPF record can be anywhere in that set. Filter on the v=spf1 prefix, never on position.

Example 2: read the DMARC record

DMARC is published one label down, at _dmarc:

Get-TxtRecord -Name _dmarc.example.com -Prefix 'v=DMARC1'

The tag that matters first is p=, which RFC 7489 lists as required and which takes none, quarantine or reject. Microsoft describes the same three as “reject, quarantine, or no instruction”. A record with p=none asks receivers to do nothing differently and only send reports, so a domain can have a valid DMARC record and no enforcement whatsoever.

The tags an audit usually wants out of that one string:

TagDocumented defaultWhat it decides
prequired, no defaultWhat a receiver should do with mail that fails DMARC
spthe value of pThe policy for subdomains, overriding inheritance
pct100The share of failing mail the policy is applied to
adkimrWhether DKIM alignment is relaxed or strict
aspfrWhether SPF alignment is relaxed or strict
ruanoneWhere aggregate reports are sent
Note RFC 7489 requires the v= tag to be first and to match DMARC1 precisely: “if it does not or it is absent, the entire retrieved record MUST be ignored”. A record with the tags in a different order is not a lenient record, it is an absent one. That is measured further down.

Example 3: read a DKIM key by selector

With the selector from prerequisite 3, the key record is an ordinary TXT lookup at a name built from three pieces. RFC 6376 spells the construction out: “Given a DKIM-Signature field with a d= tag of example.com and an s= tag of foo.bar, the DNS query will be for foo.bar._domainkey.example.com“.

Get-TxtRecord -Name selector1._domainkey.example.com -Prefix 'v=DKIM1'

The interesting part of the answer is the p= tag, the base64 public key. An empty p= is not a broken record: RFC 6376 defines it as meaning “that this public key has been revoked”, so a selector can be present, syntactically valid and deliberately dead. If you want to look at what is inside that value, encoding and decoding base64 in PowerShell covers decoding base64 from the shell.

Trap On a Microsoft 365 domain your own zone usually holds no key at all. Microsoft documents the DKIM records as CNAMEs, at the host names selector1._domainkey and selector2._domainkey, and documents the verification command as nslookup -type=CNAME selector1._domainkey.contoso.com. The TXT query still works, because a TXT lookup follows the CNAME, but the record you are reading is published in a Microsoft domain and not in yours, and an audit that expects to find a TXT record in your zone will report it missing.

Example 4: count the DNS lookups an SPF record costs

This is the example worth running before anyone adds one more include: to a record. RFC 7208 section 4.6.4 names the terms that cause DNS queries: “the 'include', 'a', 'mx', 'ptr', and 'exists' mechanisms, and the 'redirect' modifier”. It is equally explicit about the rest, which “do not cause DNS queries at the time of SPF evaluation”: “the 'all', 'ip4', and 'ip6' mechanisms, and the 'exp' modifier”. The limit is then one sentence: “SPF implementations MUST limit the total number of those terms to 10 during SPF evaluation, to avoid unreasonable load on the DNS”.

Classifying one record term by term, with PowerShell 7.4.6:

# RFC 7208 section 4.6.4 divides the terms of an SPF record in two. These cause DNS queries:
$querying = 'include','a','mx','ptr','exists'

function Get-SpfTermCost {
    param([string]$Term)
    $bare = $Term -replace '^[+\-~?]', ''        # the qualifier is not part of the name
    $name = ($bare -split '[:/=]')[0].ToLower()  # mechanism or modifier name
    if ($name -eq 'redirect')        { return 1 }
    if ($querying -contains $name)   { return 1 }
    return 0
}

$record = 'v=spf1 ip4:203.0.113.0/24 ip6:2001:db8::/32 a mx include:_spf.example.com ' +
          'exists:_h.example.com ptr:example.com exp=why.example.com -all'

$total = 0
foreach ($term in ($record -split '\s+' | Select-Object -Skip 1)) {
    $cost   = Get-SpfTermCost $term
    $total += $cost
    '{0,-28} {1}' -f $term, $(if ($cost) { '+1  counts toward the 10' } else { ' 0  free' })
}
''
'{0,-28} {1}' -f 'TOTAL', "$total DNS-querying terms in one record"
ip4:203.0.113.0/24            0  free
ip6:2001:db8::/32             0  free
a                            +1  counts toward the 10
mx                           +1  counts toward the 10
include:_spf.example.com     +1  counts toward the 10
exists:_h.example.com        +1  counts toward the 10
ptr:example.com              +1  counts toward the 10
exp=why.example.com           0  free
-all                          0  free

TOTAL                        5 DNS-querying terms in one record

Nine terms, five lookups. Note which ones are free: a record can list a hundred ip4: ranges and cost nothing, while three include: statements can cost far more than three. That is the whole trick, and it is why counting the statements in the record in front of you does not answer the question.

The real count needs the whole tree, because an include: pulls in another record whose own terms count too. The walk below runs against a lab zone held in a hashtable rather than live DNS, so the same input always produces the same number:

# A lab zone. Every record below is a fixture in this hashtable, not a live DNS answer,
# so the walk is reproducible: the same input always gives the same count.
$zone = @{
    'example.com'           = 'v=spf1 include:_spf.example.com include:mail.partner.example include:crm.vendor.example -all'
    '_spf.example.com'      = 'v=spf1 a mx include:eu.example.com ip4:203.0.113.0/24 ~all'
    'eu.example.com'        = 'v=spf1 a:mx1.eu.example.com a:mx2.eu.example.com ~all'
    'mail.partner.example'  = 'v=spf1 include:relay.partner.example ip4:198.51.100.0/24 ~all'
    'relay.partner.example' = 'v=spf1 ip4:198.51.100.128/25 ~all'
    'crm.vendor.example'    = 'v=spf1 a mx ~all'
}

$querying = 'include','a','mx','ptr','exists'
$script:lookups = 0

function Walk-Spf {
    param([string]$Domain, [int]$Depth = 0)

    $pad = ' ' * ($Depth * 4)
    if (-not $zone.ContainsKey($Domain)) { "     $pad$Domain  (no SPF record here)"; return }
    "     $pad$Domain"

    foreach ($term in ($zone[$Domain] -split '\s+' | Select-Object -Skip 1)) {
        $bare = $term -replace '^[+\-~?]', ''
        $name = ($bare -split '[:/=]')[0].ToLower()
        if ($name -ne 'redirect' -and $querying -notcontains $name) { continue }

        $script:lookups++
        '{0,3}  {1}    {2}' -f $script:lookups, $pad, $bare

        if ($name -eq 'include' -or $name -eq 'redirect') {
            Walk-Spf ($bare -split '[:=]', 2)[1] ($Depth + 1)
        }
    }
}

Walk-Spf 'example.com'
''
"include: statements typed into example.com : 3"
"DNS lookups the whole evaluation costs    : $($script:lookups)"
"result                                    : $(if ($script:lookups -gt 10) { 'permerror, the limit is 10' } else { 'within the limit' })"
     example.com
  1      include:_spf.example.com
         _spf.example.com
  2          a
  3          mx
  4          include:eu.example.com
             eu.example.com
  5              a:mx1.eu.example.com
  6              a:mx2.eu.example.com
  7      include:mail.partner.example
         mail.partner.example
  8          include:relay.partner.example
             relay.partner.example
  9      include:crm.vendor.example
         crm.vendor.example
 10          a
 11          mx

include: statements typed into example.com : 3
DNS lookups the whole evaluation costs    : 11
result                                    : permerror, the limit is 10
Trap Three include: statements, eleven lookups, and the eleventh is the one that breaks it. Nobody edited example.com to cause that: two of the eleven appeared because the vendor added an mx to their own record. An SPF record can be pushed over the limit by a change in a zone you do not control and do not get told about.

Example 5: one report for every domain you own

Reading one domain by hand is a diagnosis. The durable version is a table of every domain and subdomain the organisation sends from, which is also the only way the parked ones get noticed. This reuses the helper from prerequisite 4 and writes a file you can diff next month:

# continues in the same session as prerequisite 4, so $domains and Get-TxtRecord are already set
$report = foreach ($domain in $domains) {
    $spf   = Get-TxtRecord -Name $domain                         -Prefix 'v=spf1'
    $dmarc = Get-TxtRecord -Name "_dmarc.$domain"                -Prefix 'v=DMARC1'
    $dkim  = Get-TxtRecord -Name "selector1._domainkey.$domain"  -Prefix 'v=DKIM1'

    # the qualifier before 'all' is optional, and a bare 'all' means '+all'
    $rule = if ($spf -match '(?:^|\s)([-~?+]?)all\s*$') {
                $(if ($Matches[1]) { $Matches[1] } else { '+' }) + 'all'
            } else { 'none' }

    [pscustomobject]@{
        Domain      = $domain
        Spf         = $spf
        Dmarc       = $dmarc
        Dkim        = $dkim
        Enforcement = $rule
        Policy      = if ($dmarc -match '(?<![a-z])p\s*=\s*(none|quarantine|reject)') { $Matches[1] } else { 'MISSING' }
        HasDkim     = if ($dkim) { 'found' } else { 'MISSING' }
    }
}

$report | Format-Table Domain, Enforcement, Policy, HasDkim -AutoSize
$report | Export-Csv -Path .\mail-auth.csv -NoTypeInformation -Encoding UTF8

Both objects carry the three records exactly as they came back, so they can be compared field for field against the baseline from prerequisite 4. An empty result is the pass, which reads like a failure the first time you see it:

Compare-Object -ReferenceObject $before -DifferenceObject $report -Property Domain, Spf, Dmarc, Dkim
Careful Three details in that script are worth keeping. all is matched only at the end of the string, because a record can contain those three letters inside a host name. The qualifier in front of it is optional, and a record ending in a bare all means +all, so the script fills the default in rather than reporting nothing. And $Matches holds the groups from the last successful match, so a failed -match leaves the previous result in place: read it immediately, inside the if that tested the match, and never after a match that may not have succeeded.

Both of those patterns look obvious enough to skip testing, and both are wrong in their obvious form. A DMARC record has a second policy tag, sp=, for subdomains, and sp=reject contains the letters p=reject, so an unanchored search finds the subdomain policy and reports it as the domain policy. The all pattern has the mirror-image problem at the other end of the record. Measured against the rows that break them, with PowerShell 7.4.6:

# The two lines Example 5 uses to summarise a record, tested against the rows that break them.
$dmarc = 'v=DMARC1; sp=reject; p=none', 'v=DMARC1; p=none; sp=reject', 'v=DMARC1; sp=reject'
$spf   = 'v=spf1 include:_spf.example.com -all', 'v=spf1 include:_spf.example.com all',
         'v=spf1 a:mx.example.firewall'

'record                                 naive p=       anchored p='
foreach ($d in $dmarc) {
    $naive    = if ($d -match 'p\s*=\s*(none|quarantine|reject)')          { $Matches[1] } else { 'MISSING' }
    $anchored = if ($d -match '(?<![a-z])p\s*=\s*(none|quarantine|reject)') { $Matches[1] } else { 'MISSING' }
    '{0,-38} {1,-14} {2}' -f $d, $naive, $anchored
}

''
'record                                 naive all      anchored all'
foreach ($s in $spf) {
    $naive    = if ($s -match '([-~?+]?)all\s*$')        { ($Matches[1] + 'all') } else { 'none' }
    $anchored = if ($s -match '(?:^|\s)([-~?+]?)all\s*$') {
                    $(if ($Matches[1]) { $Matches[1] } else { '+' }) + 'all'
                } else { 'none' }
    '{0,-38} {1,-14} {2}' -f $s, $naive, $anchored
}
record                                 naive p=       anchored p=
v=DMARC1; sp=reject; p=none            reject         none
v=DMARC1; p=none; sp=reject            none           none
v=DMARC1; sp=reject                    reject         MISSING

record                                 naive all      anchored all
v=spf1 include:_spf.example.com -all   -all           -all
v=spf1 include:_spf.example.com all    all            +all
v=spf1 a:mx.example.firewall           all            none
Trap The naive patterns get four of those six rows wrong, in three different ways, and every wrong answer is plausible. A domain whose record is only sp=reject has no domain policy at all, and the unanchored search reports it as reject. A record ending in a bare all is reported without its qualifier, so a record that authorises the whole internet reads as if it authorises nothing in particular. And a host name that happens to end in those three letters is reported as an enforcement rule that is not there.

The same three lookups in CMD

A locked-down jump box does not always have PowerShell, so the nslookup form is worth having. The documented option is -type=<resourcerecordtype>:

rem Three lookups at three names. Typed at a prompt, not saved as a script.
nslookup -type=TXT example.com
nslookup -type=TXT _dmarc.example.com
nslookup -type=TXT selector1._domainkey.example.com

rem On a Microsoft 365 domain the DKIM record is a CNAME, so ask for that instead
nslookup -type=CNAME selector1._domainkey.example.com

That answers “is something published at this name”, which is most of the job. What it does not give you is a handle on the individual character-strings of the record, and Boundary 3 below is about why that matters: joining them is a decision, and in PowerShell you make it yourself. The nslookup deep dive covers the interactive mode and the set debug form, which is the one to reach for when the local resolver is the suspect.


Boundary 1: three records, three names, one record type

Every guide says “SPF, DKIM and DMARC are TXT records”, and that sentence is what makes the first lookup misleading. One record type, three names, and the name comes from somewhere different in each case:

RecordName you queryWhere the name comes fromFound by a TXT query at the apex
SPFexample.comThe domain itselfyes, among the other TXT records
DMARC_dmarc.example.comA fixed label, from RFC 7489no
DKIMselector1._domainkey.example.comA fixed label plus a selector taken from a message headerno

Two of the three rows are invisible to the query people actually run. DMARC is at least guessable, because _dmarc is the same for every domain on the internet. DKIM is not: the middle label is fixed but the first one is chosen by whoever set up signing, and DNS offers no way to list the names under a label. That is why the selector is a prerequisite and not a step.

Note Read that table as three separate questions rather than one. “Does this domain have email authentication” cannot be answered by a single lookup, and a script that claims to answer it with one is answering about SPF only.

Boundary 2: the ten is not the number of includes you typed

The limit everyone has heard of is “ten DNS lookups”, and it is easy to count wrong, because the natural thing to count is the terms in the record in front of you. Microsoft states the distinction in a parenthesis that is easy to read past: “If the number of DNS lookups (which can be different than the number of DNS queries) is greater than 10, the message fails SPF with a permanent error (also known as a permerror)”.

What the eleven in Example 4 were made of:

Where the lookup came fromCountWho controls it
include: statements written into example.com3You
Terms inside _spf.example.com, your own second record3You
Terms inside eu.example.com, reached through that record2You
Terms inside the two partner records1The partner
a and mx inside the vendor record2The vendor

Eight of the eleven are yours and three are not, and the three you do not control are the ones that move without warning. A record sitting at nine goes over on the next edit in somebody else’s zone, and yours will not have changed.

Trap A permerror is not a soft outcome. It is neither a pass nor a fail, so a receiver applying DMARC gets no usable SPF result at all, and if the domain relies on SPF alone then DMARC fails with it. Signing with DKIM is what keeps mail deliverable while an over-long SPF record is being untangled, because DMARC passes on either one.
Note RFC 7208 adds two limits that a plain term count does not cover: “The evaluation of each 'MX' record MUST NOT result in querying more than 10 address records”, the same for 'PTR', and separately “SPF implementations SHOULD limit 'void lookups' to two”, where a void lookup is a query that came back with no answer records or with a name error. A chain full of include: statements pointing at retired host names can fail on the void limit while the lookup count still looks fine.

Boundary 3: a long record arrives in pieces

A TXT record is not one string. RFC 1035 defines a character-string as “a single length octet followed by that number of characters” that “can be up to 256 characters in length (including the length octet)”, which leaves 255 for the text. A longer SPF record is therefore published as two or more strings, and RFC 7208 says what to do with them: “If a published record contains multiple character-strings, then the record MUST be treated as if those strings are concatenated together without adding spaces”.

Without adding spaces is the operative phrase. PowerShell adds one if you let it, because turning an array into a string uses $OFS, which is a space by default:

# A DNS TXT record is built from character-strings, and a character-string carries its own
# one-byte length, so 255 characters is the ceiling for one of them. A longer SPF record is
# published as two, and the split falls at character 255, not at the end of a term.
$strings = @(
    'v=spf1 ip4:203.0.113.0/24 ip4:198.51.100.0/24 ip4:192.0.2.0/24 ip6:2001:db8::/32 include:_spf.example.com include:mail.partner.example include:crm.vendor.example include:eu.example.com include:us.example.com include:archive.example.com include:billing.exa',
    'mple.com -all'
)

'string 1 : {0} characters' -f $strings[0].Length
'string 2 : {0} characters' -f $strings[1].Length

# RFC 7208 section 3.3: the strings must be concatenated together without adding spaces.
$right = $strings -join ''
# And what you get if you let PowerShell turn the array into a string: $OFS defaults to a space.
$wrong = "$strings"

''
'joined with -join      : {0} characters, {1} terms' -f $right.Length, ($right -split '\s+').Count
'interpolated as string : {0} characters, {1} terms' -f $wrong.Length, ($wrong -split '\s+').Count

$diff = Compare-Object ($right -split '\s+') ($wrong -split '\s+')
''
'terms only the interpolated version produces:'
$diff | Where-Object SideIndicator -eq '=>' | ForEach-Object { '    {0}' -f $_.InputObject }
''
'terms the interpolated version loses:'
$diff | Where-Object SideIndicator -eq '<=' | ForEach-Object { '    {0}' -f $_.InputObject }
string 1 : 255 characters
string 2 : 13 characters

joined with -join      : 268 characters, 13 terms
interpolated as string : 269 characters, 14 terms

terms only the interpolated version produces:
    include:billing.exa
    mple.com

terms the interpolated version loses:
    include:billing.example.com
Trap The corrupted version does not look corrupted. It contains include:billing.exa, which is a syntactically valid include for a domain that does not exist, and mple.com, which is a term no parser recognises. A report built that way shows a plausible-looking authorised sender, and the domain that was genuinely authorised, billing.example.com, is simply not in the list any more.

The same length problem has a second symptom on the wire. RFC 7208 recommends keeping the answer small enough that “DNS answers ought to fit in UDP packets”, and Resolve-DnsName has a -TcpOnly switch whose entire documented description is “Uses only TCP for this query”. If a TXT lookup returns nothing from one resolver and everything from another, that switch is the first thing to try, and the reason it helps is not on the page.


Boundary 4: an SPF pass is not a DMARC pass

This is the boundary that explains the support ticket. SPF can pass, the validator can be green, and the mail can still be quarantined, because the two checks are looking at different addresses. Microsoft puts the limit of SPF plainly: “SPF validates sources for senders in the MAIL FROM domain only. SPF doesn’t consider the domain in the From address or alignment between the MAIL FROM and From domains”.

DKIM has the matching gap, in Microsoft’s words: “The domain that DKIM uses to sign a message doesn’t need to match the domain in the From address”. So each of the two can succeed on a domain the recipient never sees. DMARC is the part that insists the domains line up, and RFC 7489 states the test as one sentence: a message satisfies the DMARC checks if at least one of the mechanisms “produces a pass result” and “produces that result based on an identifier that is in alignment”.

What passedAligned with the From domainDMARC outcome
SPF onlyyespass
DKIM onlyyespass
SPF onlynofail, if DKIM does not pass aligned
Bothno, on eitherfail
Neithernot applicablefail

Microsoft states the same rule from the other end: “A message passes DMARC if one or both of the described SPF or DKIM checks pass. A message fails DMARC if both of the described SPF and DKIM checks fail”. Two working mechanisms and one aligned identifier is a pass; two working mechanisms and no aligned identifier is a fail.

Note This is why a forwarded message can fail SPF and survive anyway. Forwarding changes the sending server, so the forwarder is not an authorised source for the original MAIL FROM domain, but the DKIM signature travels in the message header untouched. A domain that signs with DKIM keeps its forwarded mail; a domain relying on SPF alone does not.
Careful One sentence on the Microsoft SPF page is easy to lose and changes what ~all means in practice: “DMARC treats -all (hard fail) and ~all (soft fail) as SPF failures. But the DMARC policy is effectively ignored for SPF ~all failures if the messages don’t also contain DKIM signatures”. The recommendation on that page is -all for exactly that reason.

Boundary 5: SPF does not inherit to subdomains, DMARC does

Two records in the same zone, opposite inheritance rules, and Microsoft documents both in the same article family. On SPF: “Each subdomain that you use to send email from Microsoft 365 requires its own SPF TXT record. For example, the SPF TXT record for contoso.com doesn’t cover marketing.contoso.com; marketing.contoso.com needs its own SPF TXT record”, and more bluntly elsewhere, “Subdomains don’t inherit the SPF record of the parent domain”.

On DMARC, the word is Microsoft’s own: “Unlike SPF and DKIM, the DMARC TXT record for a domain automatically covers all subdomains (including nonexistent subdomains) that don’t have their own DMARC TXT record”.

RecordDoes a subdomain inherit itWhat that means for an unused subdomain
SPFnoNo record, so no authorised senders are defined and nothing is stated
DKIMnoNo key, so nothing signs as that subdomain
DMARCyes, including subdomains that do not existCovered by the parent policy, or by sp= if one is set

Read together, the three rows say something practical: a DMARC record at the apex is the only one of the three that covers a subdomain nobody created. That is also why Microsoft tells you to publish DMARC on domains you are not using at all. For the domains that do send, the other half of the sentence still applies: “each subdomain requires an SPF and DKIM record for DMARC”.

Good For a domain that should never send mail, the documented answer is two records: v=spf1 -all at the apex, and a DMARC record stating that no mail should ever come from it. Going through the parked domains the company owns and publishing both is short work, and it is the case Microsoft’s own parked-domain guidance is written for.

What the Resolve-DnsName page does not document

Every script in this article reads a property called Strings, and that property appears nowhere on the cmdlet reference page. The page documents sixteen parameters and four examples. The four examples resolve www.bing.com with default options, against a specified server, for A records, and with -DnsOnly, so none of them queries a TXT record.

There is an OUTPUTS section, and it is one line long. It names the type, Microsoft.DnsClient.Commands.DnsRecord, and says that the object “contains all of the records returned from the wire for the specified DNS query”. That is true and it is also where the trail stops: the section names the type and does not list a single one of its properties, so the name of the one holding the text of a TXT record is not written down there.

It is also not a recently reviewed page. The same file is published under the Windows Server 2019, 2022 and 2025 monikers, all three are stamped ms.date: 05/20/2019, and the only differences between the three are the version in the online-help link. That is checkable rather than an opinion, and it explains why every administrator’s DNS script names the property slightly differently: they each worked it out on their own machine.

Note The honest consequence is that this article shows no captured Resolve-DnsName output. The parameters are documented and are used as documented; the property name is reported as what works, with the Format-List * check in prerequisite 4 as the way to confirm it yourself. The only output blocks here are from code that does no DNS at all, measured with PowerShell 7.4.6, so you can paste it and get the same numbers.

The selection rules are worth measuring for the same reason: they are stated in the RFCs and are pure string logic, so they can be checked rather than believed. Two records where there should be one, and a version tag in the wrong place:

# Rule 1, RFC 7208 section 4.5: discard the TXT records that do not begin with 'v=spf1',
# and if more than one is left the result is permerror. Not "the first one wins".
$txt = @(
    'MS=ms12345678'
    'v=spf1 include:_spf.example.com -all'
    'google-site-verification=6Yx1bK9pQ2r'
    'v=spf1 ip4:203.0.113.10 ~all'
)

'TXT records published at example.com : {0}' -f $txt.Count
$spf = @($txt | Where-Object { $_.StartsWith('v=spf1', 'OrdinalIgnoreCase') })
'records beginning with v=spf1        : {0}' -f $spf.Count
'SPF result                           : {0}' -f
    $(if ($spf.Count -gt 1) { 'permerror' } elseif ($spf.Count -eq 1) { $spf[0] } else { 'none' })

# Rule 2, RFC 7489 section 6.3: 'v' must be the first tag and must be exactly DMARC1,
# or the entire retrieved record is ignored. An ignored record is not a lenient record.
''
$candidates = @(
    'v=DMARC1; p=reject; rua=mailto:dmarc@example.com'
    'p=reject; v=DMARC1; rua=mailto:dmarc@example.com'
    'v=dmarc1; p=reject'
    'v=DMARC2; p=reject'
)

foreach ($c in $candidates) {
    $first = ($c -split ';')[0].Trim()
    $ok    = $first -cmatch '^v=DMARC1$'
    '{0,-50} {1}' -f $c, $(if ($ok) { 'used' } else { "ignored, first tag is '$first'" })
}
TXT records published at example.com : 4
records beginning with v=spf1        : 2
SPF result                           : permerror

v=DMARC1; p=reject; rua=mailto:dmarc@example.com   used
p=reject; v=DMARC1; rua=mailto:dmarc@example.com   ignored, first tag is 'p=reject'
v=dmarc1; p=reject                                 ignored, first tag is 'v=dmarc1'
v=DMARC2; p=reject                                 ignored, first tag is 'v=DMARC2'

Four TXT records at the apex, two of them SPF, and the result is permerror rather than a winner. Microsoft says the same thing in operational terms: “Multiple SPF TXT records for the same domain or subdomain cause SPF to return permerror (the receiving system can’t determine which record to evaluate), so use only one SPF record per domain or subdomain”. It is the classic outcome of two people adding a sender at different times, each leaving the other record alone.

Careful The three ignored DMARC rows are the rules as the RFC writes them, implemented literally. “The value of this tag MUST match precisely” is what rejects the lower-case row, and “It MUST be the first tag in the list” is what rejects the reordered one. What the block proves is the parsing, not the behaviour of any particular receiver.

Quick reference: the three records side by side

SPFDKIMDMARC
Nameexample.com<selector>._domainkey.example.com_dmarc.example.com
Record typeTXTTXT, or CNAME on Microsoft 365TXT
Starts withv=spf1v=DKIM1v=DMARC1
AuthenticatesThe MAIL FROM domainThe signing domain in d=Alignment between those and the From header
One per domainyes, more than one is a permerrorone per selectoryes
Inherited by subdomainsnonoyes
Read it withResolve-DnsName example.com -Type TXTResolve-DnsName selector1._domainkey.example.com -Type TXTResolve-DnsName _dmarc.example.com -Type TXT

And the enforcement rule at the end of an SPF record, which is the part an audit reads first:

QualifierRFC 7208 nameMicrosoft’s nameWhat it asks a receiver to do
-allfailhard failReject mail from sources not listed
~allsoftfailsoft failAccept but mark it
?allneutralneutralNothing specific, used for testing
+allpassnot listed thereAuthorise every sender on the internet
Trap +all is the qualifier to look for on a domain you have inherited. It is syntactically valid, it makes every SPF check pass, and it authorises the whole internet to send as that domain. A record ending in a bare all means exactly the same thing, because RFC 7208 states that “the qualifier is optional and defaults to '+'“. That is the row the script in Example 5 fills the default in for.

Tips and limitations

  • A TXT query at the apex finds SPF only. _dmarc and _domainkey need their own lookups, and the DKIM one needs a selector that DNS will not give you.
  • The ten-lookup limit counts DNS-querying terms across the whole recursive evaluation, not the include: statements in the record you are looking at. ip4:, ip6: and all cost nothing.
  • Exceeding it produces permerror, which is neither a pass nor a fail, so a domain relying on SPF alone fails DMARC with it.
  • Join the character-strings of a TXT record without spaces. Letting PowerShell interpolate the array inserts one, and the result can be a valid-looking include for a domain that does not exist.
  • More than one record starting v=spf1 is a permerror, not a first-one-wins.
  • A DMARC record whose v= tag is absent, misspelled or not first is ignored in its entirety, which is indistinguishable from having no DMARC record at all.
  • SPF authenticates the MAIL FROM domain and DKIM authenticates its own d= domain. Neither is obliged to match the From address a human reads, and DMARC is the only one of the three that checks that they do.
  • SPF and DKIM are not inherited by subdomains; DMARC is, including subdomains that do not exist.
  • v=spf1 -all is the documented SPF record for a domain that should never send mail.
  • A record can be pushed past the lookup limit by an edit in a zone you do not control, so the count is worth re-running on a schedule rather than once.

Where this matters

Mail from one subdomain goes to Junk and the parent domain is fine. The parent has an SPF record and the subdomain does not, because SPF is not inherited. The DMARC record at the apex does cover the subdomain, so the mail is being judged against a policy with no SPF record behind it.

A new SaaS sender was added and unrelated mail started failing. The new include: pushed the recursive count past ten, and every message from the domain now gets a permerror instead of a pass. Example 4 is the check that would have caught it before the change went in.

An audit says DKIM is missing and the tenant says it is enabled. On Microsoft 365 the zone holds CNAMEs at selector1._domainkey, not keys, so a check looking for a TXT record in your own zone reports nothing. The documented verification is a CNAME query.

Two administrators each added a sender and mail stopped authenticating. Two records starting v=spf1 is a permerror, and neither person sees anything wrong with their own record.

A DMARC record was published and nothing changed. p=none asks receivers to do nothing differently and only report, so the record is valid, the reports arrive, and no mail is affected until the policy is raised.

Forwarded mail fails and direct mail passes. Forwarding breaks SPF because the forwarding server is not an authorised source for the original MAIL FROM domain. DKIM survives it, so the domain needs to be signing.


Official documentation


  • SPF Record Validator: paste a domain and read its SPF record back with the include chain walked for you
  • DMARC Record Validator: check the policy, the alignment mode and the reporting addresses in a DMARC record
  • DNS Lookup Tool: query TXT and any other record type at an arbitrary name, which is what the _dmarc and _domainkey lookups need

Five cheat sheets, one PDF

Subnet masks, PowerShell, Linux commands, HTTP status codes and the ESXi command line - one page each, free to keep. Leave an address and it arrives in a minute.