DMARC DNS Record Implementation Guide

Comprehensive technical documentation of all DMARC DNS record fields, validation constraints, and implementation requirements per RFC 7489.

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

Phase 2: Gradual Enforcement

# 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

rf (Report Format)

  • Type: Enumeration
  • Value: "afrf" (only supported value)
  • Status: Removed in DMARCbis (no other formats ever implemented)

DMARC Testing and Validation Tools

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