Email Security: SPF, DKIM & DMARC in Practice
Email is still the most common way attacks begin, and many of those emails pretend to come from a domain people trust: a bank, a college, a supplier or your own company. In Social Engineering & Phishing you learned what SPF, DKIM and DMARC are and how to read an Authentication-Results header as a receiver. This lesson takes the domain owner's side: how to write the records correctly, how alignment really works, how to read DMARC reports, and how to move a real domain safely to p=reject.
Everything here is about domains you own or manage. Use your own lab domain or your organisation's domain with approval from its owner.
Two "from" addresses
Every email has two sender addresses, and this is the key to understanding the whole topic:
- The envelope sender (also called
MAIL FROMorReturn-Path): used by mail servers for bounces. Users never see it. SPF checks this domain. - The header From: the address shown in the inbox. This is what people trust and what attackers fake.
SPF and DKIM can both pass for a domain that has nothing to do with the address the user sees. DMARC fixes this by requiring alignment: the domain that passed SPF or DKIM must match the header From domain.
SPF: listing your senders
An SPF record is a TXT record on the domain. It lists mechanisms that match allowed senders, each with an optional qualifier, and ends with all to cover everything else:
coachingcentre.example. TXT "v=spf1 ip4:203.0.113.25 include:_spf.mailprovider.example include:bulkmailer.example -all"| Part | Meaning |
|---|---|
ip4: / ip6: | A specific address or range may send |
include: | Also allow whatever another domain's SPF record allows |
a, mx | Allow the domain's own A or MX hosts |
-all | Everything else fails |
~all | Everything else soft-fails (accepted but marked) |
Three rules trip up many administrators. A domain must have only one SPF record. The receiver may perform at most 10 DNS lookups while evaluating it (each include, a, mx, ptr, exists and redirect costs one, and includes inside includes also count); beyond that, SPF returns an error. And the ptr mechanism should not be used at all. A small checker catches these:
import re
LOOKUP_NAMES = {"include", "a", "mx", "ptr", "exists", "redirect"}
def check_spf(records):
spf = [r for r in records if r.lower().startswith("v=spf1")]
if len(spf) != 1:
return [f"Expected exactly one SPF record, found {len(spf)}"]
problems, lookups = [], 0
for term in spf[0].split()[1:]:
name = re.split(r"[:=/]", term.lstrip("+-~?").lower())[0]
if name in LOOKUP_NAMES:
lookups += 1
if name == "ptr":
problems.append("ptr mechanism should not be used")
if term in ("+all", "all"):
problems.append(f"'{term}' allows the whole internet to send as you")
problems.append(f"{lookups} lookup(s) at the top level (limit 10 including nested includes)")
return problems
print(check_spf(["v=spf1 ip4:203.0.113.25 include:_spf.mailprovider.example mx -all"]))
print(check_spf(["v=spf1 include:a.example -all", "v=spf1 include:b.example -all"]))
print(check_spf(["v=spf1 ptr a mx +all"]))['2 lookup(s) at the top level (limit 10 including nested includes)'] ['Expected exactly one SPF record, found 2'] ['ptr mechanism should not be used', "'+all' allows the whole internet to send as you", '3 lookup(s) at the top level (limit 10 including nested includes)']
Two SPF records usually appear when a second email service is added and its setup guide says "add this TXT record". The fix is to merge both into one record.
DKIM: signing your mail
With DKIM, the sending server signs each message with a private key, and adds a DKIM-Signature header naming the signing domain (d=) and a selector (s=). The receiver fetches the public key from DNS at selector._domainkey.domain:
dig +short TXT mail2026._domainkey.coachingcentre.example"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3k..."
Every service that sends mail as your domain (your mailbox provider, the newsletter tool, the fee-receipt system) needs its own DKIM key published under your domain, so that d= matches your From domain. Use 2048-bit RSA keys, and rotate them by publishing a new selector, switching the sender to it, and removing the old selector later. Selectors make rotation possible without any gap.
DMARC: policy, alignment and reports
The DMARC record lives at _dmarc.domain and is a list of tags:
| Tag | Meaning |
|---|---|
p | Policy for the domain: none, quarantine or reject |
sp | Policy for subdomains (defaults to p) |
rua | Where to send daily aggregate reports |
adkim, aspf | Alignment mode: r relaxed (default) or s strict |
pct | Percentage of failing mail the policy applies to, used during rollout |
Relaxed alignment accepts the same organisational domain (mail.coachingcentre.example aligns with coachingcentre.example); strict requires an exact match. Finding the organisational domain needs the Public Suffix List, because for college.co.in the suffix is co.in, not in. This example uses a tiny suffix list to show the idea:
PUBLIC_SUFFIXES = {"in", "co.in", "ac.in", "example"}
def org_domain(domain):
labels = domain.lower().split(".")
for i in range(len(labels)):
if ".".join(labels[i + 1:]) in PUBLIC_SUFFIXES:
return ".".join(labels[i:])
return domain
def parse_dmarc(record):
tags = dict(part.strip().split("=", 1) for part in record.split(";") if "=" in part)
tags.setdefault("adkim", "r")
tags.setdefault("sp", tags.get("p"))
return tags
def aligned(auth_domain, from_domain, mode):
if mode == "s":
return auth_domain.lower() == from_domain.lower()
return org_domain(auth_domain) == org_domain(from_domain)
policy = parse_dmarc("v=DMARC1; p=quarantine; rua=mailto:dmarc@college.co.in; pct=50")
print("Policy:", policy["p"], "| subdomains:", policy["sp"], "| applies to", policy["pct"] + "%")
for dkim_d in ["mail.college.co.in", "college.co.in", "mailer.example"]:
print(f"d={dkim_d:<20} relaxed={aligned(dkim_d, 'college.co.in', 'r')!s:<5} strict={aligned(dkim_d, 'college.co.in', 's')}")Policy: quarantine | subdomains: quarantine | applies to 50% d=mail.college.co.in relaxed=True strict=False d=college.co.in relaxed=True strict=True d=mailer.example relaxed=False strict=False
The last line is the common case for third-party senders: the newsletter tool signs with its own domain, DKIM passes, but it does not align, so DMARC fails for your From address. The fix is to set up DKIM for your domain inside that tool.
Reading aggregate reports
With rua set, large receivers send a daily XML report: which IP addresses sent mail claiming your domain, how many messages, and whether SPF and DKIM passed and aligned. These reports are how you discover forgotten senders before you enforce a strict policy:
import xml.etree.ElementTree as ET
report = """<feedback>
<policy_published><domain>college.co.in</domain><p>none</p></policy_published>
<record><row><source_ip>203.0.113.25</source_ip><count>840</count>
<policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated></row>
<identifiers><header_from>college.co.in</header_from></identifiers></record>
<record><row><source_ip>198.51.100.40</source_ip><count>212</count>
<policy_evaluated><disposition>none</disposition><dkim>fail</dkim><spf>fail</spf></policy_evaluated></row>
<identifiers><header_from>college.co.in</header_from></identifiers></record>
<record><row><source_ip>192.0.2.77</source_ip><count>9</count>
<policy_evaluated><disposition>none</disposition><dkim>fail</dkim><spf>fail</spf></policy_evaluated></row>
<identifiers><header_from>college.co.in</header_from></identifiers></record>
</feedback>"""
root = ET.fromstring(report)
print("Domain:", root.findtext("policy_published/domain"), "policy:", root.findtext("policy_published/p"))
for rec in root.iter("record"):
ip = rec.findtext("row/source_ip")
count = int(rec.findtext("row/count"))
dkim, spf = rec.findtext("row/policy_evaluated/dkim"), rec.findtext("row/policy_evaluated/spf")
verdict = "PASS" if "pass" in (dkim, spf) else "FAIL"
print(f"{ip:<15} {count:>4} msgs dkim={dkim:<4} spf={spf:<4} DMARC {verdict}")Domain: college.co.in policy: none 203.0.113.25 840 msgs dkim=pass spf=pass DMARC PASS 198.51.100.40 212 msgs dkim=fail spf=fail DMARC FAIL 192.0.2.77 9 msgs dkim=fail spf=fail DMARC FAIL
Investigate every failing source before tightening the policy. Here, 212 messages from one IP might be the college's own fee-reminder system that nobody added to SPF or DKIM, while the 9 messages from another address could be spoofing. Only after legitimate sources pass is it safe to quarantine or reject. In practice, a DMARC reporting service or an open-source parser does this for thousands of records.
A safe rollout plan
- Inventory every system that sends mail as the domain: mailbox provider, website forms, CRM, billing, HR tools.
- Publish SPF with all of them and set up DKIM for each one.
- Publish DMARC with
p=noneand anruaaddress, and read reports for a few weeks. - Fix every legitimate failing source.
- Move to
p=quarantine, optionally withpctto start with a share of failing mail, then top=reject. - Keep reading reports; new tools get added all the time.
Domains that never send mail (old brand names, parked domains) are easy to protect immediately: publish v=spf1 -all, a DMARC record with p=reject, and a "null MX" record (MX 0 .) to show they do not accept mail.
Beyond the big three
- MTA-STS and TLS-RPT: let other servers know your domain requires TLS-encrypted delivery, and send you reports when it fails.
- Inbound protection: a mail gateway with attachment sandboxing, link checking and an "external sender" banner.
- Block automatic forwarding of company mail to personal addresses, a common way data leaks after an account is compromised.
Common mistakes
- Two SPF records after adding a new email service.
- Too many includes, going over the 10-lookup limit, so SPF fails for every message.
- Third-party senders without your own DKIM, which fail alignment.
- Jumping straight to
p=rejectwithout reading reports, which blocks your own invoices and OTP mails. - Staying at
p=nonefor ever, which only monitors and protects nobody.
Key points
- SPF checks the envelope sender; users see the header From; DMARC requires the two to align.
- One SPF record per domain, at most 10 DNS lookups, no
ptr, end with-allor~all. - DKIM keys live at
selector._domainkey.domain; use 2048-bit keys and rotate them with new selectors. - DMARC tags:
p,sp,rua,adkim,aspf,pct; relaxed alignment compares organisational domains. - Read aggregate reports, fix legitimate senders, then move from
nonetoquarantinetoreject. - Protect non-sending domains with
v=spf1 -all, DMARCp=rejectand a null MX.
Exercise
Write a function dmarc_advice(record) that uses parse_dmarc and returns a list of advice strings: warn if p=none, warn if there is no rua, and warn if sp is weaker than p (order none < quarantine < reject). Test it with "v=DMARC1; p=reject; sp=none".
Show solution
def parse_dmarc(record):
tags = dict(part.strip().split("=", 1) for part in record.split(";") if "=" in part)
tags.setdefault("sp", tags.get("p"))
return tags
STRENGTH = {"none": 0, "quarantine": 1, "reject": 2}
def dmarc_advice(record):
tags = parse_dmarc(record)
advice = []
if tags.get("p") == "none":
advice.append("p=none only monitors; plan a move to quarantine and reject")
if "rua" not in tags:
advice.append("No rua: you will receive no aggregate reports")
if STRENGTH[tags["sp"]] < STRENGTH[tags["p"]]:
advice.append(f"Subdomains (sp={tags['sp']}) are weaker than the domain (p={tags['p']})")
return advice
for line in dmarc_advice("v=DMARC1; p=reject; sp=none"):
print("-", line)It reports the missing rua and the weak subdomain policy. Attackers like sp=none because they can send from made-up subdomains such as fees.college.co.in without being rejected.
Quick Quiz
1. Which address does SPF check?
That is why DMARC alignment is needed to connect SPF to the visible From address.
2. A domain's SPF record needs 13 DNS lookups. What happens?
The limit is 10 lookups; flatten or remove includes to stay under it.
3. A newsletter tool signs mail with d=mailer.example for a From address at college.co.in. DKIM passes. Does DMARC pass on DKIM?
Set up DKIM for your own domain in the tool so the signature aligns.
4. What is the safest first DMARC policy for a domain with many senders?
Reports show which legitimate senders must be fixed before enforcing.
Learn Cyber Security with a mentor
Free lessons get you started. Our Ethical Hacking & Cyber Security training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
