Two green results and a quarantined message. The results were correct. They were about the wrong domain.
An analyst pulls the header from a message that landed in a finance mailbox claiming to be from the company's own accounts team. The first line of the authentication results says spf=pass. The next says dkim=pass. The message was quarantined anyway, and the ticket asks why the filter is blocking legitimate internal mail.
It is not internal, it is not legitimate, and the filter did exactly what it was told. The two passes are real. They just belong to somebody else.
What a pass is actually about
Every authentication result in a mail header is a statement about one specific domain, and the domain is written next to the result. SPF answers whether the connecting server was authorized to send for the envelope domain, the address the sending system used for bounces, which the reader never sees. DKIM answers whether the message carries a valid signature from the signing domain, whichever domain the sending system chose to sign with. Neither check looks at the From line.
Here is the header from the ticket, in the format Exchange Online actually stamps rather than the tidy version in the RFC:
Authentication-Results: spf=pass (sender IP is 198.51.100.24)
smtp.mailfrom=bounce.mail-partner-cdn.net; dkim=pass (signature was verified)
header.d=mail-partner-cdn.net; dmarc=fail action=quarantine
header.from=northgateeng.com; compauth=fail reason=000The format is worth a sentence, because it is the one you will actually meet. Exchange Online omits the authserv-id that the RFC puts first, carries a parenthetical reason beside each verdict, and appends its own compauth composite at the end. A header that opens with a server name and has no compauth came from somewhere else, which matters when you are deciding whose evaluation you are reading.
Read the domains, not the verdicts. SPF passed for bounce.mail-partner-cdn.net. DKIM passed for mail-partner-cdn.net. The From line, the only domain a person in finance will ever see, is northgateeng.com. Neither pass was issued to it.
DMARC is the check that asks the question the other two skip: does either passing domain match the visible one? That comparison is alignment, and here both sides fail it. So dmarc=fail, and because the domain publishes a policy that asks receivers to act on failure, the message went to junk.
Now hold two explanations against that header, because you cannot separate them from the results alone. This is either an attacker who has learned that a passing signature from any domain is cheap, or the organization's own marketing platform configured exactly as its vendor's setup guide said, quietly sending as the platform rather than as the company. The header looks identical in both cases. Which one you are looking at is a question about your own sender inventory, and that is the part most estates have never written down.
pct= tag is removed and ignored: a receiver applying it is running an old implementation, and a rollout plan built on it has no mechanism underneath.
Find the same shape in your own telemetry
The header above is one message. The same pattern, a pass that belongs to a domain other than the From, is visible across a whole tenant in Defender's EmailEvents table, which carries the authentication verdicts as a JSON string per message. This query pulls out inbound mail that displays your domain, passed at least one check, and still failed DMARC. Every row is either an impersonation or one of your own senders that nobody got signing as you.
// Table: EmailEvents (Microsoft Defender for Office 365, Advanced Hunting / Sentinel)
// Hypothesis: mail displaying our domain that passed SPF or DKIM for SOME domain
// and still failed DMARC is either a forgery or an unaligned sender of ours.
// Replace northgateeng.com with the domain you own.
EmailEvents
| where Timestamp > ago(30d)
| where EmailDirection == "Inbound"
| where SenderFromDomain =~ "northgateeng.com"
| extend Auth = parse_json(AuthenticationDetails)
| extend SPF = tostring(Auth.SPF), DKIM = tostring(Auth.DKIM), DMARC = tostring(Auth.DMARC)
| where DMARC =~ "fail" and (SPF =~ "pass" or DKIM =~ "pass")
| summarize Messages = count(),
Recipients = dcount(RecipientEmailAddress),
FirstSeen = min(Timestamp), LastSeen = max(Timestamp),
Delivered = countif(DeliveryAction == "Delivered"),
Junked = countif(DeliveryAction == "Junked"),
SampleSubject = any(Subject)
by SenderIPv4, SenderMailFromDomain, SPF, DKIM
| order by Messages desc
// Read the rows in two groups. A source with steady weekday volume and an
// envelope domain you recognize as a platform you pay for is YOUR sender,
// authenticating as the platform: a ticket to get it signing as you.
// A source with no history, high volume, and recipients you have never
// written to is somebody else's campaign. Delivered > 0 on that row means
// your policy is still at none.Two things about the query are deliberate. It filters on SenderFromDomain, the visible From, rather than on SenderMailFromDomain, because the question is what the reader saw, and grouping by the envelope domain afterwards is what separates a platform you pay for from a stranger. And it keeps rows where either check passed, because a forgery that passes nothing is already caught by every filter on earth; the interesting population is the one that passed something and still failed to be you.
The Delivered column is the one to read first. If every row shows delivered and nothing junked, the domain's DMARC policy is p=none, which asks receivers to report failures and take no action. That is monitoring, and it protects nobody; the forged invoice arrives exactly as the real one does.
The same read without a SIEM
Most of what governs this is in public DNS, so the posture of any domain, yours or a supplier's, is readable in about ninety seconds with nothing but a resolver. This checks the three records that matter, and counts the one thing that breaks silently.
# Any domain, any machine. Replace the domain.
d=northgateeng.com
# 1. Is there a policy, and does it ask receivers to act? p=none means no.
dig +short TXT _dmarc.$d
# 2. The SPF record, and how many DNS lookups it spends against the limit of 10.
# include:, a, mx, redirect= and exists: each cost one; ip4:/ip6: cost nothing.
dig +short TXT $d | grep -i v=spf1
dig +short TXT $d | grep -i v=spf1 | grep -oE '(include:|redirect=|\ba\b|\bmx\b|exists:)' | wc -l
# 3. Does an invented subdomain inherit anything? Nothing back means the receiver
# walks up to the organizational record; check that record carries np= or sp=.
dig +short TXT _dmarc.zq7x-not-a-name.$dThe third query is the one people skip. A name that has never existed in your zone returns nothing, so the receiver walks up to the organizational record, and under RFC 9989 that record's np= tag is what governs it. A record with no np= and no sp= leaves every invented subdomain, invoices., payments., hr., at whatever the apex policy is, and an apex at p=none means an attacker can pick any name under your domain and be delivered.
The lookup count is the number to write down. Eight is a conversation with a supplier; ten is the ceiling; eleven is a permanent error that fails every sender you have, and the first sign of it is usually a customer asking why your invoices are in junk.
On the Microsoft side, one more check answers whether your own platform is signing as you or as itself. Exchange Online rotates between two selectors on its own schedule, and a tenant that published only one CNAME signs perfectly until the rotation and then fails totally, with no change on the administrator's side.
# Exchange Online PowerShell. Shows both selector CNAMEs the tenant expects to exist.
# If only one of these is published in your DNS, you are one rotation from a
# total DKIM failure that will look, in the reports, like every sender broke at once.
Connect-ExchangeOnline
Get-DkimSigningConfig -Identity northgateeng.com |
Select-Object Domain, Enabled, Selector1CNAME, Selector2CNAME, RotateOnDate
# Then confirm both names resolve from outside the tenant:
Resolve-DnsName -Type CNAME selector1._domainkey.northgateeng.com
Resolve-DnsName -Type CNAME selector2._domainkey.northgateeng.comThe detection for the other direction
Everything above is about mail claiming your domain. The mirror case is a forged message that authenticates flawlessly, because the attacker registered a lookalike domain and configured SPF, DKIM and DMARC on it correctly. Three passes, all real, all for a domain the reader will misread. No authentication mechanism detects that, by design; the passes are honest. The detection is on the display name and the domain edit distance, and it belongs in a Sigma rule rather than a policy.
title: Inbound Mail Impersonating Internal Display Name From External Domain
id: 3f7a2c1e-8b4d-4e6a-9c2f-1d5e7b8a9c0d
status: experimental
description: External sender using the display name of an internal executive or finance role. Field names follow the Defender XDR EmailEvents schema; map them to your backend.
references:
- https://attack.mitre.org/techniques/T1656/
- https://attack.mitre.org/techniques/T1566/
logsource:
product: m365
service: exchange
detection:
selection_name:
SenderDisplayName|contains:
- 'Rachel Okafor'
- 'Accounts Payable'
- 'Finance Team'
filter_internal:
SenderFromDomain: 'northgateeng.com'
condition: selection_name and not filter_internal
falsepositives:
- Staff mailing from a personal address, which is itself worth a conversation
- A partner whose staff share a name with yours
level: mediumThe names in the rule are the ones an attacker would use against you, which means they are the ones on your website's leadership page and the two or three role mailboxes that approve money. Five is about right; twenty makes the rule fire on every partner whose finance team shares a common name, and the rule stops being read.
Read the two detections as a pair. The KQL query above finds mail that fails to be you. This rule finds mail that succeeds at looking like you while being, in every technical sense, honestly somebody else. Authentication closes the first box completely once a policy is enforced. It never touched the second, and an organization that credits it with both is the one that gets surprised.
What to do this week
- Run the KQL above against your tenant for the last 30 days and sort the rows into two piles: senders you pay for that are authenticating as themselves, and sources you cannot name. The first pile is tickets; the second is the reason the policy exists.
- Count your SPF lookups with the one-liner and write the number down with today's date. At eight or above, find the two terms authorizing hosts that do not send mail (
aandmxare the usual suspects) and remove them before a supplier's restructure pushes you over. - Publish both DKIM selector CNAMEs, and confirm both resolve from outside the tenant. If you can only find one, you have not had a rotation yet; you will.
- Read the policy tag on your own
_dmarcrecord. If it saysp=noneand has for more than a quarter, you have the reports and none of the protection, and the KQL results from step one are the sender inventory that makes moving it safe. - Deploy the Sigma rule with your own five names in it, then run the ATT&CK T1656 impersonation technique through your mail flow from an external free-mail account. If the message lands in an inbox with no warning banner, the display-name gap is open regardless of what your DMARC policy says.
A pass is a fact about a domain. Before it means anything, ask which domain it was issued to, and whether that is the one the person reading the message believed they were hearing from. Most of email authentication is that one question, asked carefully, three times.