# Email Domain Security Auditor: SPF, DKIM, DMARC, DNSSEC (`meshsmith/email-domain-security-auditor`) Actor

Bulk-audit domains for SPF, DMARC, DKIM, MX, MTA-STS, TLS-RPT, BIMI, DNSSEC and CAA. Each domain gets a 0-100 score, an A-F grade and fixes. Point-in-time check of public DNS records. Not a security audit, guarantee, or certification. Run only on domains you own or are authorized to assess.

- **URL**: https://apify.com/meshsmith/email-domain-security-auditor.md
- **Developed by:** [Rowland](https://apify.com/meshsmith) (community)
- **Categories:** Developer tools, Automation, Business
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 domain auditeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Email Domain Security Auditor

**Audit hundreds of domains for SPF, DKIM, DMARC, MTA-STS, TLS-RPT, BIMI, DNSSEC, CAA and MX in one run, and get an A–F grade with plain-language fixes for every domain.**

> **Point-in-time check of public DNS records. Not a security audit, guarantee, or certification. Run only on domains you own or are authorized to assess.**
>
> Do not use to identify domains for spoofing, phishing, or unsolicited email.

### What does it do?

Paste a list of domains, URLs or email addresses. The Actor reads each domain's public DNS records, checks them against the email-authentication and DNS-security standards, and returns **one row per domain** with:

- a **0–100 score** and an **A–F grade**,
- every **issue found**, ranked by severity (critical, high, medium, low, info),
- a **specific fix** for each issue,
- an **`insurerReady`** flag for the DMARC/SPF baseline that cyber-insurance and vendor-security questionnaires commonly ask about, with an `insurerReadyNote` on every row (no insurer has reviewed, endorsed or accepted this flag or the grade),
- the parsed records behind every verdict, so you can check the evidence.

Export the results to CSV, Excel or JSON, or send them to a spreadsheet, CRM or ticketing tool with Apify integrations.

### What it checks

| Check | What you learn |
|---|---|
| **MX** | Whether the domain receives mail, the detected mail provider, and Null MX (RFC 7505) on domains that send no mail |
| **SPF** | The parsed record and its `all` qualifier (`-all`, `~all`, `?all`, `+all`). DNS lookups are counted against the 10-lookup limit, with includes and redirects followed recursively. Also reports void lookups, broken includes, `ptr` use and duplicate records |
| **DKIM** | Probes 17 common selectors plus any you add, and reports key type, RSA key size, revoked keys and test mode. Wildcard DNS is detected, so it doesn't report false positives |
| **DMARC** | `p`, `sp`, `pct`, `rua`, `ruf`, `adkim`/`aspf` alignment, fallback to the organizational domain, tag errors, and whether third-party report addresses are authorized |
| **MTA-STS** | The `_mta-sts` TXT record, the HTTPS policy file (mode, `max_age`), and whether every MX host is covered by the policy |
| **TLS-RPT** | The `_smtp._tls` record and its report destination |
| **BIMI** | The `default._bimi` record, logo URL and whether a VMC/CMC certificate is present |
| **DNSSEC** | `signed`, `unsigned`, `bogus` (DS present but validation fails) or `ds-present-not-validated` |
| **CAA** | Which certificate authorities may issue certificates for the domain (inherited from parent domains), wildcard rules and `iodef` |

### Grades and fixes

Every issue deducts points: **critical −35, high −20, medium −10, low −4** (info costs nothing). Domains start at 100.

| Grade | Score | Typical meaning |
|---|---|---|
| **A** | 90–100 | Enforcing DMARC, a clean SPF record, only optional extras missing |
| **B** | 80–89 | Solid basics, with a few medium or low items to tidy up |
| **C** | 65–79 | Gaps that weaken spoofing protection or reporting |
| **D** | 50–64 | Significant weaknesses |
| **F** | below 50 | Spoofable, or broken records |

Each issue comes with a recommendation written for the person who edits the DNS. For example:

| Severity | Issue | Recommendation |
|---|---|---|
| medium | DMARC has no rua= address, so you get no aggregate reports about who sends as your domain. | Add `rua=mailto:<reports address>` to the record. |
| low | DNSSEC is not enabled, so DNS answers (including SPF/DMARC/MX) can be spoofed in transit. | Enable DNSSEC signing at your DNS host and add the DS record at your registrar. |
| low | DKIM selector "k1" uses a 1024-bit RSA key. | Rotate to a 2048-bit RSA key when convenient. |

`insurerReady` is `true` when DMARC is enforcing (`quarantine` or `reject` at `pct=100`), there is exactly one SPF record, and that record isn't `+all`. DKIM is reported separately (`dkimSelectorsFound`), because selectors can't be listed from DNS. Every row also carries:

> `insurerReadyNote`: "Meets common cyber-insurer email-auth baselines (SPF, DKIM, DMARC enforcement); not reviewed or endorsed by any insurer"

The flag is a technical self-check against common questionnaire items. It is not an insurer's decision: no insurer has reviewed or endorsed this Actor, and none is bound to accept its grade or flag. Your insurer's own questionnaire and assessment always decide.

### Who is it for?

- **IT admins and security teams** checking their own domains and brands before an audit, or after changing mail providers.
- **MSPs and IT consultants** reviewing many client domains at once and turning the `issues` list into tickets.
- **Anyone asked for email-authentication evidence**: cyber-insurance applications, vendor security questionnaires, or proof that DMARC is enforced.
- **Deliverability and marketing ops** making sure SPF, DKIM and DMARC are aligned before a campaign.

### How it works, and what it never does

- **Public DNS only.** It queries public DNS-over-HTTPS resolvers, with an automatic fallback resolver.
- **At most one HTTPS GET per domain.** It fetches the MTA-STS policy file, and only when the domain advertises one.
- **Passive checks only.** No port scans, no SMTP connections, no email sent, no subdomain brute-forcing.
- **Polite by default.** A global DNS rate limit (40 queries/second), a concurrency cap, a per-domain timeout, exponential backoff with jitter on rate limits (HTTP 429), SERVFAIL and timeouts, failover to the second public resolver, a hard stop if the resolvers keep refusing (it never rotates IPs or works around a provider's limits), and a cache for repeated lookups such as shared SPF includes.
- **No personal data.** Mailbox names in DNS records or in your input are redacted (`redacted@example.com`). Only domain-level configuration is returned.

### Input

| Field | Default | Notes |
|---|---|---|
| `domains` (required) | – | Domains, URLs or email addresses. Inputs are normalized, lower-cased, IDN-converted and de-duplicated. `www.` is stripped by default |
| `dkimSelectors` | `[]` | Your own DKIM selectors, probed in addition to the 17 common ones |
| `checkDkim` | `true` | Turn off to save about 18 lookups per domain |
| `fetchMtaStsPolicy` | `true` | Download and validate the MTA-STS policy file |
| `verifyExternalDmarcReports` | `true` | Check authorization records for third-party `rua`/`ruf` domains |
| `resolver` | `doh-cloudflare` | `doh-cloudflare`, `doh-google` or `system` |
| `concurrency` | `10` | Domains audited in parallel (1–50) |
| `maxQueriesPerSecond` | `40` | Global DNS rate limit (1–40; 40 is the hard maximum) |
| `domainTimeoutSecs` | `60` | A slower domain is returned with `status: "error"` and is not charged |
| `stripWww` | `true` | Audit `example.com` when you enter `www.example.com` |
| `maxDomains` | `10000` | Safety cap per run |

Example input:

```json
{
  "domains": [
    "example.com",
    "https://www.example.org/contact",
    "someone@example.net"
  ],
  "dkimSelectors": ["mycustomselector"],
  "concurrency": 10
}
```

### Output

You get one dataset row per unique input. The **Overview** view shows the summary columns. The **Issues and fixes** view lists one row per issue. Full details for each check (parsed records, includes, selectors, policies) are in the nested objects `mx`, `spf`, `dmarc`, `dkim`, `mtaSts`, `tlsRpt`, `bimi`, `dnssec` and `caa`.

A real row from a test run on Apify, shortened:

```json
{
  "domain": "example.com",
  "status": "ok",
  "score": 86,
  "grade": "B",
  "insurerReady": true,
  "insurerReadyNote": "Meets common cyber-insurer email-auth baselines (SPF, DKIM, DMARC enforcement); not reviewed or endorsed by any insurer",
  "summary": "Grade B (86/100): 1 medium, 1 low, 1 info",
  "mxProvider": null,
  "spfAll": "-all",
  "spfLookups": 0,
  "dmarcPolicy": "reject",
  "dkimSelectorsFound": [],
  "mtaStsMode": null,
  "tlsRptPresent": false,
  "bimiPresent": false,
  "dnssecStatus": "signed",
  "caaPresent": false,
  "issueCount": 2,
  "criticalCount": 0,
  "issues": [
    {
      "check": "dmarc",
      "severity": "medium",
      "issue": "DMARC has no rua= address, so you get no aggregate reports about who sends as your domain.",
      "recommendation": "Add rua=mailto:<reports address> to the record."
    },
    {
      "check": "caa",
      "severity": "low",
      "issue": "No CAA record: any certificate authority may issue certificates for this domain.",
      "recommendation": "Publish CAA records on example.com naming only the CAs you use, e.g. 0 issue \"letsencrypt.org\"."
    },
    {
      "check": "mx",
      "severity": "info",
      "issue": "Null MX published: the domain explicitly accepts no email.",
      "recommendation": "Make sure SPF is \"v=spf1 -all\" and DMARC is p=reject so the domain cannot be spoofed."
    }
  ],
  "spf": { "record": "v=spf1 -all", "allQualifier": "-all", "dnsLookups": 0, "lookupLimitExceeded": false },
  "dmarc": { "record": "v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s", "policy": "reject", "subdomainPolicy": "reject", "pct": 100 },
  "dnssec": { "status": "signed", "dsPresent": true, "validated": true },
  "lookupErrors": [],
  "durationMs": 2477
}
```

A domain that doesn't exist is returned as a free row:

```json
{
  "domain": "this-domain-should-not-exist-zz91.com",
  "status": "domain_not_found",
  "score": null,
  "grade": null,
  "issues": []
}
```

#### `status` values

| Status | Meaning | Charged? |
|---|---|---|
| `ok` | Complete audit | **Yes** |
| `incomplete` | Some DNS lookups failed, so the result may be partial | No |
| `domain_not_found` | The domain doesn't exist (NXDOMAIN) | No |
| `invalid_input` | The entry couldn't be read as a domain | No |
| `error` | A timeout or unexpected failure | No |

A run summary is saved in the default key-value store under `SUMMARY`: counts by status, the grade distribution, the number of charged rows, and the DNS queries used.

### Pricing

**Pay per event: $0.003 per domain audited**, which is $3 per 1,000 domains.

- You're charged **only for complete rows** (`status: "ok"`).
- Invalid entries, non-existent domains, timeouts and incomplete audits are free.
- Duplicates are removed before auditing, so you never pay twice for the same domain in one run.
- To cap spending, set **Maximum cost per run** in the run options. When the cap is reached, the Actor stops cleanly and the `SUMMARY` record shows how many domains were skipped.

### FAQ

**Why does DKIM say "not found" when my domain signs mail?**
DNS has no way to list DKIM selectors, so the Actor has to guess them. It probes 17 common ones. If your provider uses a custom selector, add it to `dkimSelectors`. You can find it as the `s=` value in the `DKIM-Signature` header of an email you sent. When `_domainkey` exists but no common selector matches, the Actor reports "custom selector likely" (low severity), not missing DKIM.

**Why isn't my grade an A even though email works?**
The grade measures protection against spoofing and downgrade attacks, not delivery. Missing optional protections such as MTA-STS, TLS-RPT or DNSSEC each cost a few points. The `issues` list explains every deduction and how to fix it.

**Does it send email or connect to mail servers?**
No. It makes no SMTP connections and sends no messages. It reads public DNS and, at most, one public MTA-STS policy file per domain.

**Can I check domains I don't own?**
Every check reads public records that any receiving mail server reads. Even so, run it only against domains you own or are authorized to assess.

**Why is a result `incomplete`?**
A DNS lookup failed after a retry, for example a SERVFAIL, a timeout or a broken DNSSEC chain. The row lists the failed lookups in `lookupErrors` and isn't charged. Re-run those domains later.

**How fast is it?**
A domain needs roughly 25–35 DNS queries. At the default limit of 40 queries per second, 1,000 domains take about 15 minutes. The rate limit is the bottleneck by design, to stay polite to public resolvers.

**Can I monitor my domains on a schedule?**
Yes. Save your input as a task and schedule it. Compare `score` and `issues` between runs to catch regressions, such as a removed DMARC record or an SPF record that went over the lookup limit.

**Is this legal or compliance advice?**
No. The results describe DNS configuration as seen at run time. Use them as technical evidence, not as a certification. This is a point-in-time check of public DNS records, not a security audit, guarantee, or certification.

**How do I report a problem?**
Open an issue on this Actor's **Issues** tab with the domain and the row you got.

### Limitations

- **DKIM selectors are guessed.** Only the 17 common selectors and the ones you supply are checked (see the FAQ).
- **DNSSEC status comes from the public resolver.** It's based on the validating resolver's AD flag; the Actor doesn't validate signatures itself. For subdomains, the status reflects that exact name.
- **Organizational domain detection** (used for DMARC fallback) relies on a built-in list of common multi-label suffixes such as `co.uk` and `com.au`, not the full Public Suffix List.
- **Point-in-time results.** DNS is read as public resolvers see it at run time, so recent changes can take up to the record's TTL to show.
- **MTA-STS** checks the policy file once and doesn't follow redirects, as RFC 8461 requires.
- **Spending cap:** when the maximum cost per run is reached, audits already in progress are discarded and not charged.

> Point-in-time check of public DNS records. Not a security audit, guarantee, or certification. Run only on domains you own or are authorized to assess.
>
> Do not use to identify domains for spoofing, phishing, or unsolicited email.

# Actor input Schema

## `domains` (type: `array`):

Domains to audit. You can also paste URLs or email addresses; only the domain part is used. Duplicates are removed.

## `dkimSelectors` (type: `array`):

Your own DKIM selectors (the s= value in a DKIM-Signature header). 17 common selectors are always probed.

## `checkDkim` (type: `boolean`):

Turn off to skip about 18 DNS lookups per domain.

## `fetchMtaStsPolicy` (type: `boolean`):

When the \_mta-sts TXT record exists, download the policy over HTTPS (one GET request per domain, no redirects) and check its mode and MX coverage.

## `verifyExternalDmarcReports` (type: `boolean`):

Check that third-party rua/ruf destinations publish the authorization record that receivers require.

## `resolver` (type: `string`):

How DNS is queried. DNSSEC status always uses a validating DNS-over-HTTPS resolver.

## `concurrency` (type: `integer`):

Number of domains audited at the same time.

## `maxQueriesPerSecond` (type: `integer`):

Global rate limit for DNS queries, to stay polite to public resolvers (hard maximum 40). One domain needs about 25-35 queries, and repeated lookups are cached. Rate limits, SERVFAIL and timeouts get exponential backoff with jitter; the run stops if resolvers keep refusing.

## `domainTimeoutSecs` (type: `integer`):

A domain that takes longer is reported with status "error" and is not charged.

## `stripWww` (type: `boolean`):

Audit example.com when you enter www.example.com.

## `maxDomains` (type: `integer`):

Safety cap on the number of unique domains audited in this run.

## Actor input object example

```json
{
  "domains": [
    "apify.com",
    "example.com"
  ],
  "dkimSelectors": [],
  "checkDkim": true,
  "fetchMtaStsPolicy": true,
  "verifyExternalDmarcReports": true,
  "resolver": "doh-cloudflare",
  "concurrency": 10,
  "maxQueriesPerSecond": 40,
  "domainTimeoutSecs": 60,
  "stripWww": true,
  "maxDomains": 10000
}
```

# Actor output Schema

## `overview` (type: `string`):

All audited domains (default dataset) with score, A-F grade, insurerReady flag and per-check summary columns.

## `issues` (type: `string`):

One row per issue found (default dataset, issues view): check, severity, issue and recommended fix.

## `summary` (type: `string`):

JSON record SUMMARY in the default key-value store: counts by status, grade distribution, charged rows, DNS queries used.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "domains": [
        "apify.com",
        "example.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("meshsmith/email-domain-security-auditor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "domains": [
        "apify.com",
        "example.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("meshsmith/email-domain-security-auditor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "domains": [
    "apify.com",
    "example.com"
  ]
}' |
apify call meshsmith/email-domain-security-auditor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,meshsmith/email-domain-security-auditor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/f48aGAKo6YKAZnmSa/builds/B5RdaSxq61b0zk650/openapi.json
