The DMARC standard (RFC 7489, March 2015) defines 11 official fields published in DNS TXT records at _dmarc.domain.com. The upcoming DMARCbis (expected 2025) will deprecate some tags while maintaining v=DMARC1 compatibility.
DMARC Record Syntax (ABNF Grammar)
DMARC records use Augmented Backus-Naur Form (ABNF) per RFC 5234. The complete grammar specification:
; Complete ABNF Grammar for DMARC Records (RFC 7489)
dmarc-record = dmarc-version dmarc-sep dmarc-request
*( dmarc-sep dmarc-tag )
dmarc-version = "v" *WSP "=" *WSP %s"DMARC1"
dmarc-request = "p" *WSP "=" *WSP dmarc-preq
dmarc-preq = "none" / "quarantine" / "reject"
dmarc-sep = *WSP ";" *WSP
dmarc-tag = dmarc-atag / dmarc-ftag / dmarc-otag / dmarc-ptag /
dmarc-rtag / dmarc-stag
; Alignment tags
dmarc-atag = "adkim" *WSP "=" *WSP ("r" / "s")
dmarc-asfpt = "aspf" *WSP "=" *WSP ("r" / "s")
; Percentage tag (being replaced by binary logic in DMARCbis)
dmarc-ptag = "pct" *WSP "=" *WSP 1*3DIGIT
; Reporting tags
dmarc-rtag = "rua" *WSP "=" *WSP dmarc-uri
/ "ruf" *WSP "=" *WSP dmarc-uri
dmarc-uri = URI [ "!" 1*DIGIT [ "k" / "m" / "g" / "t" ] ]
*( "," URI [ "!" 1*DIGIT [ "k" / "m" / "g" / "t" ] ] )
; Subdomain policy
dmarc-stag = "sp" *WSP "=" *WSP dmarc-preq
; Failure reporting options
dmarc-ftag = "fo" *WSP "=" *WSP dmarc-fopt
dmarc-fopt = "0" / "1" / "d" / "s" / (dmarc-fv *(";" dmarc-fv))
dmarc-fv = "0" / "1" / "d" / "s"
; Tags deprecated in DMARCbis
dmarc-otag = "ri" *WSP "=" *WSP 1*DIGIT
/ "rf" *WSP "=" *WSP "afrf"
Critical Syntax Rules
-
Mandatory ordering: v=DMARC1 MUST be first tag, p= MUST be second
-
Case sensitivity: Tag names lowercase, DMARC1 uppercase, policy values lowercase
-
Whitespace handling: Optional around = and ; separators
-
String concatenation: Multiple DNS TXT strings automatically concatenated
-
Maximum length: Individual TXT strings limited to 255 characters
Mandatory separators: Semicolon (;) between tags, equals sign (=) between tag and value, comma (,) for URI lists.
Mandatory Fields
v (Version)
-
Type: Fixed string
-
Value: "DMARC1" (case-sensitive)
-
Position: First mandatory tag
-
Validation: Exact match required
p (Policy)
-
Type: Enumeration
-
Values: "none" | "quarantine" | "reject"
-
Position: Second mandatory tag
-
Function: Processing policy for primary domain
Optional Control Fields (Detailed Specifications)
sp (Subdomain Policy)
; Syntax
sp-tag = "sp" *WSP "=" *WSP ( "none" / "quarantine" / "reject" )
; Validation Rules
- MUST use exact lowercase string values
- If omitted, subdomains inherit parent domain policy (p=)
- Applies to all subdomains (*.example.com)
- Does not affect parent domain processing
Subdomain Inheritance Logic: When sp= is absent, mail.example.com inherits the p= policy from example.com. When sp= is present, it overrides this inheritance for all subdomains.
adkim (DKIM Alignment Mode)
; Syntax
adkim-tag = "adkim" *WSP "=" *WSP ( "r" / "s" )
;
Alignment Behavior
Relaxed (r):
DKIM d= organizational domain matches From: domain
- From:
[email protected]
- DKIM d=example.com → PASS (organizational match)
Strict (s): DKIM d= domain exactly matches From: domain
- From:
[email protected]
- DKIM d=example.com → FAIL (exact match required)
- DKIM d=mail.example.com → PASS (exact match)
aspf (SPF Alignment Mode)
; Syntax
aspf-tag = "aspf" *WSP "=" *WSP ( "r" / "s" )
; Alignment Behavior
Relaxed (r):
SPF-validated domain organizationally matches From:
- From:
[email protected]
- SPF validates example.com → PASS
Strict (s): SPF-validated domain exactly matches From:
- From:
[email protected]
- SPF validates example.com → FAIL
- SPF validates mail.example.com → PASS
Organizational Domain Determination
DMARC alignment currently uses the Public Suffix List (PSL) to determine organizational domains. DMARCbis will replace this with DNS Tree Walk algorithm:
# Examples of organizational domain matching
example.com → example.com (base domain)
mail.example.com → example.com (subdomain of base)
api.mail.example.com → example.com (nested subdomain)
# Special cases with PSL
example.co.uk → example.co.uk (co.uk is public suffix)
mail.example.co.uk → example.co.uk (organizational match)
# Edge cases
example.github.io → example.github.io (github.io is public suffix)
api.example.github.io → example.github.io (organizational match)
# DMARCbis Tree Walk (upcoming)
# Traverses DNS hierarchy up to 7 labels to find DMARC records
# Replaces external PSL dependency with pure DNS approach
Reporting Fields
rua (Aggregate Report URI) - Detailed Specification
The "Daily News" of Your Domain. Think of rua as your daily executive summary. It tells you who is sending email as your domain and how those emails are performing against authentication checks (SPF/DKIM).
These reports are XML files sent periodically (usually daily) to the addresses you list. They provide aggregated data and statistics (e.g., "IP 1.2.3.4 sent 500 emails claiming to be you; 490 passed SPF"). While they do not contain email message content, they **do include source IP addresses and other identifiers**. These identifiers can be considered Personal Data (PII) under privacy regulations like GDPR. Therefore, handling RUA reports still requires appropriate data protection considerations.
; Complete ABNF for RUA
rua-tag = "rua" *WSP "=" *WSP rua-uri-list
rua-uri-list = rua-uri *( "," rua-uri )
rua-uri = "mailto:" addr-spec [ "!" max-size ]
max-size = 1*DIGIT [ size-unit ]
size-unit = "k" / "m" / "g" / "t" ; kilo/mega/giga/tera
; Size Limits (bytes)
k = 1024
m = 1024^2 (1,048,576)
g = 1024^3 (1,073,741,824)
t = 1024^4 (1,099,511,627,776)
External Domain Validation (RFC 7489 Section 7.1)
The "Knock on the Door" Rule. You cannot simply tell the internet to dump your reports on someone else's doorstep without their permission. This prevents malicious actors from "spamming" a victim's inbox with millions of DMARC reports from random domains.
If your domain is example.com but you want reports sent to security-service.com, the receiving domain (security-service.com) must explicitly publish a DNS record saying, "Yes, I accept reports from example.com."
# Scenario: example.com sending reports to
[email protected]
# 1. The DMARC record at example.com
v=DMARC1; p=none; rua=mailto:
[email protected]
# 2. The REQUIRED "Permission Slip" at external.com
# DNS Host: external.com
# Record Name: example.com._report._dmarc
# Record Type: TXT
# Value: v=DMARC1
# Without this "digital handshake," reports are silently discarded.
ruf (Forensic Report URI) - Technical Details
The "Crime Scene Investigation" Kit. While RUA gives you statistics, ruf attempts to give you the smoking gun. When a specific email fails authentication, this triggers a "Failure Report" containing the actual message headers and sometimes the body.
⚠️ Privacy Warning: Because these reports contain real user data (Subject lines, email content), they are a privacy minefield (GDPR, etc.). Consequently, most major email providers (Gmail, Yahoo, Microsoft) refuse to send them. They are rarely used in modern production environments.
; RUF uses identical syntax to RUA
ruf-tag = "ruf" *WSP "=" *WSP rua-uri-list
; Privacy Concerns (Why rarely implemented)
- Contains full message headers
- May include message body content
- Privacy regulations (GDPR) compliance issues
- High volume potential (per-message vs daily aggregate)
- Major providers (Gmail, Yahoo, Microsoft) no longer send
; Recommended: Use RUA only for most implementations
fo (Failure Options) - Complete Specification
; ABNF Grammar
fo-tag = "fo" *WSP "=" *WSP fo-option-list
fo-option-list = fo-option *( ":" fo-option )
fo-option = "0" / "1" / "d" / "s"
; Detailed Semantics
0 - Generate report if both SPF and DKIM produce something
other than "pass" (default behavior)
1 - Generate report if either SPF or DKIM produces something
other than "pass"
d - Generate report if DKIM evaluation fails to produce "pass"
s - Generate report if SPF evaluation fails to produce "pass"
; Combination Examples
fo=0 - Both must fail (most restrictive)
fo=1 - Either fails (most reports)
fo=1:d - Either fails OR DKIM specifically fails
fo=d:s - DKIM fails OR SPF fails (equivalent to fo=1)
Failure Option Processing Logic
# Pseudo-code for fo= processing
def should_generate_ruf_report(spf_result, dkim_result, fo_options):
spf_fail = spf_result != "pass"
dkim_fail = dkim_result != "pass"
for option in fo_options:
if option == "0" and spf_fail and dkim_fail:
return True
elif option == "1" and (spf_fail or dkim_fail):
return True
elif option == "d" and dkim_fail:
return True
elif option == "s" and spf_fail:
return True
return False
Critical Validation Constraints
Mandatory tag ordering: The most common error is incorrect ordering. v=DMARC1 must be first, followed by p=.
Strict syntax validation: Enumerated values are case-sensitive (except DMARC1). A value "Reject" instead of "reject" invalidates the entire record.
DNS TXT constraints: Maximum length of 255 characters per string. Long records require automatic concatenation of multiple strings.
Cross-domain verification: For rua and ruf URIs pointing to external domains, a DNS record _report._dmarc.external-domain is mandatory to authorize report reception.
DMARC Record Examples and Validation
Progressive Deployment Examples
Phase 1: Initial Monitoring
# Minimal monitoring record
v=DMARC1; p=none; rua=mailto:
[email protected]
# Enhanced monitoring with forensic reports
v=DMARC1; p=none; rua=mailto:
[email protected]; ruf=mailto:
[email protected]; fo=1
# Quarantine with percentage rollout
v=DMARC1; p=quarantine; pct=25; rua=mailto:
[email protected]
# Note: While pct allows values 0-100, many experts recommend
# using only pct=0 (testing) or pct=100 (enforcement)
# to avoid inconsistent handling by receivers.
Phase 3: Full Protection
# Strict alignment with subdomain protection
v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s; rua=mailto:
[email protected]
# Production enterprise configuration
v=DMARC1; p=reject; sp=quarantine; rua=mailto:
[email protected]!50m,mailto:
[email protected]
Complex Configuration Examples
Multi-Domain Reporting
# Company using external DMARC service
v=DMARC1; p=quarantine; rua=mailto:
[email protected],mailto:
[email protected]!100m
# Required authorization at external service
# DNS: example.com._report._dmarc.dmarcanalyzer.com. IN TXT "v=DMARC1"
High-Volume Domain Configuration
# Large organization with size limits and backup reporting
v=DMARC1; p=reject; sp=none; rua=mailto:
[email protected]!500m,mailto:
[email protected]!1g; adkim=r; aspf=r
Edge Cases and Special Configurations
Subdomain-Only Protection
# Protect subdomains while leaving parent domain open
v=DMARC1; p=none; sp=reject; rua=mailto:
[email protected]
# Use case: Parent domain doesn't send email,
# but subdomains (mail.example.com) do
DNS TXT Record Splitting (Long Records)
# Single logical record split across multiple TXT strings
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:
[email protected]!10m"
"mailto:backup-reports-with-another-very-long-email@monitoring.external-service.com!50m; adkim=s; aspf=s; sp=quarantine"
# DNS automatically concatenates: "v=DMARC1; p=reject; rua=mailto:..."
Common Invalid Records (Anti-Patterns)
# ❌ INVALID: Wrong tag order
p=reject; v=DMARC1; rua=mailto:
[email protected]
# ❌ INVALID: Case sensitivity error
v=dmarc1; p=REJECT; rua=mailto:
[email protected]
# ❌ INVALID: Missing mandatory p= tag
v=DMARC1; rua=mailto:
[email protected]
# ❌ INVALID: Invalid policy value
v=DMARC1; p=block; rua=mailto:
[email protected]
# ❌ INVALID: Malformed URI
v=DMARC1; p=none;
[email protected] ; missing mailto:
# ❌ INVALID: Percentage out of range
v=DMARC1; p=quarantine; pct=150; rua=mailto:
[email protected]
DMARC Record Generation and Validation Algorithm
Standard Generation Algorithm
# DMARC Record Generation (Pseudocode)
def generate_dmarc_record(policy, options={}):
# Step 1: Initialize with mandatory tags
record = "v=DMARC1"
record += "; p=" + policy # policy must be none|quarantine|reject
# Step 2: Add optional tags in recommended order
if 'sp' in options:
record += "; sp=" + options['sp']
if 'adkim' in options:
record += "; adkim=" + options['adkim'] # r|s
if 'aspf' in options:
record += "; aspf=" + options['aspf'] # r|s
if 'pct' in options and options['pct'] != 100:
record += "; pct=" + str(options['pct']) # 0-100
if 'rua' in options:
record += "; rua=" + format_uri_list(options['rua'])
if 'ruf' in options:
record += "; ruf=" + format_uri_list(options['ruf'])
if 'fo' in options:
record += "; fo=" + ":".join(options['fo']) # 0,1,d,s
# Step 3: Validate total length and split if needed
return split_dns_record_if_needed(record)
def format_uri_list(uri_list):
formatted = []
for uri_info in uri_list:
uri = uri_info['address']
if 'size_limit' in uri_info:
uri += "!" + str(uri_info['size_limit']) + uri_info['unit']
formatted.append(uri)
return ",".join(formatted)
Validation Algorithm
# DMARC Record Validation (Pseudocode)
def validate_dmarc_record(record_string):
errors = []
# Parse tag-value pairs
tags = parse_dmarc_tags(record_string)
# Rule 1: First tag must be v=DMARC1
if not tags or tags[0] != ('v', 'DMARC1'):
errors.append("First tag must be v=DMARC1")
# Rule 2: Second tag must be p=
if len(tags) < 2 or not tags[1][0] == 'p':
errors.append("Second tag must be p=")
# Rule 3: Validate policy values
if tags[1][1] not in ['none', 'quarantine', 'reject']:
errors.append("Invalid policy value: " + tags[1][1])
# Rule 4: Validate all tag syntax
for tag_name, tag_value in tags:
if tag_name == 'sp' and tag_value not in ['none', 'quarantine', 'reject']:
errors.append("Invalid sp value: " + tag_value)
elif tag_name == 'adkim' and tag_value not in ['r', 's']:
errors.append("Invalid adkim value: " + tag_value)
elif tag_name == 'aspf' and tag_value not in ['r', 's']:
errors.append("Invalid aspf value: " + tag_value)
elif tag_name == 'pct':
try:
pct_val = int(tag_value)
if not 0 <= pct_val <= 100:
errors.append("pct must be 0-100")
except ValueError:
errors.append("pct must be integer")
elif tag_name in ['rua', 'ruf']:
validate_uri_list(tag_value, errors)
return errors
def validate_uri_list(uri_string, errors):
for uri in uri_string.split(','):
if not uri.strip().startswith('mailto:'):
errors.append(f"URI must use mailto: scheme: {uri}")
# Additional URI validation...
DNS Record Length Handling
# Handle DNS TXT record 255-character limit
def split_dns_record_if_needed(record):
if len(record) <= 255:
return record
# Split at word boundaries, preferring after semicolons
parts = []
current_part = ""
for char in record:
if len(current_part) + 1 > 255 and char in [';', ' ']:
parts.append(current_part)
current_part = ""
current_part += char
if current_part:
parts.append(current_part)
# Return as multiple DNS TXT strings
return parts
# Example DNS zone file format for long records
_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:
[email protected]"
"additional tags and values continue here"
IANA DMARC Tag Registry
The official IANA registry defines 11 standardized tags:
-
RFC 7489 tags: adkim, aspf, fo, p, pct, rf, ri, rua, ruf, sp, v
-
DMARCbis changes: Removes pct, rf, ri; adds np (non-existent policy), psd (public suffix domain)
-
Note: Records remain v=DMARC1 for backward compatibility
Deprecated Fields (DMARCbis)
The following fields are being deprecated in DMARCbis and should be avoided in new implementations:
pct (Percentage)
-
Type: Integer
-
Range: 0-100
-
Default: 100
-
Status: Likely to be deprecated in DMARCbis in favor of binary logic
-
Issues: Only pct=0 and pct=100 work reliably; intermediate values inconsistent
-
Migration: Stick to pct=0 for testing and pct=100 for enforcement
ri (Report Interval)
-
Type: Integer (seconds)
-
Default: 86400 (24h)
-
Status: Removed in DMARCbis (rarely honored by receivers)
-
Reality: Most receivers send daily regardless of ri value
-
Type: Enumeration
-
Value: "afrf" (only supported value)
-
Status: Removed in DMARCbis (no other formats ever implemented)
Command-Line Validation
# Check DMARC DNS record
dig TXT _dmarc.example.com +short
# Test from multiple DNS servers
for server in 8.8.8.8 1.1.1.1 208.67.222.222; do
echo "Testing $server:"
dig @$server TXT _dmarc.example.com +short
done
# Validate DMARC syntax with Python
python3 -c "
import re
record = 'v=DMARC1; p=reject; rua=mailto:
[email protected]'
if re.match(r'^v=DMARC1;\s*p=(none|quarantine|reject)\b', record):
print('Valid DMARC record')
else:
print('Invalid DMARC record')
"
Comprehensive DMARC Testing Script
#!/bin/bash
# Comprehensive DMARC record validation script
DOMAIN="$1"
if [ -z "$DOMAIN" ]; then
echo "Usage: $0 domain.com"
exit 1
fi
echo "🔍 Testing DMARC for $DOMAIN..."
# Check if DMARC record exists
# Using tr -d ' ' simplifies parsing by removing whitespace
DMARC_RECORD=$(dig TXT "_dmarc.$DOMAIN" +short | tr -d '"' | tr -d ' ')
if [ -z "$DMARC_RECORD" ]; then
echo "❌ No DMARC record found at _dmarc.$DOMAIN"
exit 1
fi
echo "📧 DMARC Record: $DMARC_RECORD"
# Validate version tag (must be first)
# Note: We removed spaces, so it must start with v=DMARC1
if [[ "$DMARC_RECORD" =~ ^v=DMARC1 ]]; then
echo "✅ Valid version tag (v=DMARC1)"
else
echo "❌ Invalid or missing version tag (Must start with v=DMARC1)"
fi
# Check policy tag (must be second)
# Regex matches ;p= followed by policy value
if [[ "$DMARC_RECORD" =~ \;p=(none|quarantine|reject) ]]; then
# Extract policy value
POLICY=$(echo "$DMARC_RECORD" | sed -n 's/.*;p=\([^;]*\).*/\1/p')
echo "✅ Valid policy: $POLICY"
else
echo "❌ Invalid or missing policy tag (Must be second tag)"
fi
# Check for reporting addresses
if [[ "$DMARC_RECORD" =~ rua=mailto: ]]; then
# Extract RUA
RUA=$(echo "$DMARC_RECORD" | sed -n 's/.*rua=\([^;]*\).*/\1/p')
echo "📊 Aggregate reporting: $RUA"
else
echo "⚠️ No aggregate reporting configured"
fi
# Validate alignment modes
if [[ "$DMARC_RECORD" =~ adkim=([rs]) ]]; then
ADKIM=$(echo "$DMARC_RECORD" | sed -n 's/.*adkim=\([rs]\).*/\1/p')
echo "🔒 DKIM alignment: $([ "$ADKIM" = "s" ] && echo "strict" || echo "relaxed")"
fi
if [[ "$DMARC_RECORD" =~ aspf=([rs]) ]]; then
ASPF=$(echo "$DMARC_RECORD" | sed -n 's/.*aspf=\([rs]\).*/\1/p')
echo "🔒 SPF alignment: $([ "$ASPF" = "s" ] && echo "strict" || echo "relaxed")"
fi
# Check subdomain policy
if [[ "$DMARC_RECORD" =~ sp=(none|quarantine|reject) ]]; then
SP=$(echo "$DMARC_RECORD" | sed -n 's/.*sp=\([^;]*\).*/\1/p')
echo "🏢 Subdomain policy: $SP"
else
# If policy is defined, we can reference it
if [ -n "$POLICY" ]; then
echo "🏢 Subdomain policy: inherits from main policy ($POLICY)"
else
echo "🏢 Subdomain policy: unknown (main policy missing)"
fi
fi
echo "✅ DMARC validation complete for $DOMAIN"
Technical Implementation: This specification provides the complete foundation for implementing RFC 7489 compliant DMARC record generators and validators. All syntax rules, validation constraints, and edge cases are comprehensively documented.
Common Implementation Errors: Wrong tag ordering, case sensitivity mistakes, invalid policy values, malformed URIs, missing external domain authorization, and exceeding DNS record length limits. Always validate against this specification.
Additional Resources