EthicsComputerCourses
Join training +91 90532 10052

Email Security: SPF, DKIM & DMARC in Practice

Cyber Security · Lesson 33 of 44 Intermediate 8 min read Updated

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 FROM or Return-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:

Text
coachingcentre.example.  TXT  "v=spf1 ip4:203.0.113.25 include:_spf.mailprovider.example include:bulkmailer.example -all"
PartMeaning
ip4: / ip6:A specific address or range may send
include:Also allow whatever another domain's SPF record allows
a, mxAllow the domain's own A or MX hosts
-allEverything else fails
~allEverything 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:

PythonRun it yourself
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"]))
Output
['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:

Terminal
dig +short TXT mail2026._domainkey.coachingcentre.example
Output
"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:

TagMeaning
pPolicy for the domain: none, quarantine or reject
spPolicy for subdomains (defaults to p)
ruaWhere to send daily aggregate reports
adkim, aspfAlignment mode: r relaxed (default) or s strict
pctPercentage 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:

PythonRun it yourself
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')}")
Output
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:

PythonRun it yourself
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}")
Output
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

  1. Inventory every system that sends mail as the domain: mailbox provider, website forms, CRM, billing, HR tools.
  2. Publish SPF with all of them and set up DKIM for each one.
  3. Publish DMARC with p=none and an rua address, and read reports for a few weeks.
  4. Fix every legitimate failing source.
  5. Move to p=quarantine, optionally with pct to start with a share of failing mail, then to p=reject.
  6. 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=reject without reading reports, which blocks your own invoices and OTP mails.
  • Staying at p=none for 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 -all or ~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 none to quarantine to reject.
  • Protect non-sending domains with v=spf1 -all, DMARC p=reject and 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
Python
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?

2. A domain's SPF record needs 13 DNS lookups. What happens?

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?

4. What is the safest first DMARC policy for a domain with many senders?

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