Skip to content

DNS Security

DNS Security provides DNS-level threat prevention for your managed fleet by integrating with third-party DNS filtering providers and enforcing allowlist/blocklist policies. The system ingests DNS security events from connected providers, categorizes threats, logs blocked queries, and gives you aggregated statistics to understand threat exposure across your organization. Breeze integrates with an existing DNS filtering provider you already run or subscribe to – it is not itself a DNS resolver.

You manage DNS Security from the DNS Security page in the dashboard sidebar. The page has tabs for an overview, blocked-event feed, policy management, and provider integrations. The API endpoints below back the same workflows for scripts and automations.

Integrations connect Breeze to your existing DNS filtering infrastructure. Each integration is scoped to an organization and stores encrypted API credentials for the target provider. Once connected, Breeze periodically syncs DNS security events from the provider and stores them in the dns_security_events table, where they can be queried, filtered, and analyzed. Policies define domain-level allowlists and blocklists that are pushed back to the provider through a sync pipeline.


  1. Connect a DNS filter provider by creating an integration with the provider’s API credentials. Breeze encrypts all secrets at rest.

  2. Breeze syncs events from the provider on a configurable interval. Blocked and redirected queries are stored with their action, threat category, source device, and domain. Allowed queries are stored only when the integration opts in; see Stored traffic.

  3. Create policies to define allowlists and blocklists. Policies are linked to a specific integration and contain domain entries with optional reasons.

  4. Policies sync to the provider through BullMQ. When you add or remove domains, a sync job is enqueued to push changes upstream to the DNS provider.

  5. Analyze threat data using the stats, top-blocked, and events endpoints. Breeze automatically aggregates data for efficient querying over long time ranges.

Action Description
allowed DNS query was resolved normally
blocked DNS query was blocked by the provider’s filtering rules
redirected DNS query was redirected to a different address (e.g., a block page or sinkhole)
Category Description
malware Domain associated with malware distribution or command-and-control infrastructure
phishing Domain used in phishing campaigns to harvest credentials or personal data
botnet Domain linked to botnet command-and-control networks
cryptomining Domain associated with unauthorized cryptocurrency mining
ransomware Domain used in ransomware delivery or payment infrastructure
spam Domain associated with spam campaigns
adware Domain serving intrusive advertising or adware payloads
adult_content Domain hosting adult or explicit content
gambling Domain hosting online gambling services
social_media Social media platform (typically used for content filtering policies)
streaming Streaming media platform (typically used for bandwidth management policies)
unknown Category not determined by the provider

Breeze supports the following DNS filter providers:

Provider Enum Value Required Config
Cisco Umbrella umbrella apiKey, apiSecret
Cloudflare Gateway cloudflare apiKey, config.accountId
DNSFilter dnsfilter apiKey
Pi-hole pihole apiKey, config.apiEndpoint, optional config.piholeVersion
AdGuard Home adguard_home apiKey, apiSecret, config.apiEndpoint

For AdGuard Home, apiKey is the admin username and apiSecret is the admin password (the provider uses HTTP Basic Auth). config.apiEndpoint is the base URL of your AdGuard Home instance (e.g., https://adguard.example.com).

For Cisco Umbrella, apiKey and apiSecret come from an Umbrella API Keys credential with the Reports scope. Breeze exchanges them for an OAuth2 bearer token and reads DNS activity from https://api.umbrella.com/reports/v2/activity. No organization ID is needed — the token’s own subject claim scopes every call to your org. config.organizationId is still accepted on existing integrations, but it is no longer required and is not sent anywhere.

For Cloudflare Gateway, apiKey is an API token for the Zero Trust account named by config.accountId. Give it Account Analytics: Read (DNS events are read from the Gateway analytics dataset through the GraphQL API) and Zero Trust: Edit (to add and remove domains on the lists named by config.blocklistId and config.allowlistId). The dataset is grouped, not per query, so events carry the Gateway location but no source IP; per-query logs with device attribution need Enterprise Logpush.

Pi-hole supports both the legacy v5 admin API and the newer v6 session-based API. Set config.piholeVersion to v5 or v6 to match your Pi-hole installation’s major version.

The meaning of apiKey also changes between versions: for Pi-hole v5, apiKey is the legacy admin API token (found under the Pi-hole web interface’s API/Web settings). For Pi-hole v6, apiKey instead holds the app password — a separate v6-specific credential generated in Pi-hole’s admin settings for API access. These two credential types are not interchangeable — pasting a v5 admin token into a v6 integration (or an app password into a v5 integration) will cause authentication to fail.

Terminal window
POST /dns-security/integrations
Content-Type: application/json
Authorization: Bearer <token>
{
"orgId": "uuid",
"provider": "umbrella",
"name": "Contoso Umbrella",
"description": "Cisco Umbrella for Contoso HQ",
"apiKey": "your-api-key",
"apiSecret": "your-api-secret",
"config": {
"syncInterval": 60,
"retentionDays": 90,
"categories": ["malware", "phishing", "botnet"]
},
"isActive": true
}
Field Type Description
orgId UUID Organization ID. Auto-resolved for org-scoped tokens
provider string DNS provider: umbrella, cloudflare, dnsfilter, pihole, adguard_home
name string Human-readable name (max 200 chars)
description string Optional description (max 2,000 chars)
apiKey string Provider API key (required, max 5,000 chars). For Pi-hole, the expected value depends on config.piholeVersion — see below
apiSecret string Provider API secret (required for Cisco Umbrella)
config.organizationId string Provider’s organization ID. Optional for Cisco Umbrella — accepted for backward compatibility but not used
config.accountId string Provider’s account ID (required for Cloudflare Gateway)
config.apiEndpoint string Custom API endpoint URL (required for Pi-hole)
config.piholeVersion string Pi-hole API version: v5 or v6. Defaults to v5 if unset. Determines whether apiKey is treated as the legacy admin token (v5) or the app password (v6)
config.syncInterval integer Event sync interval in minutes (5-1440, default varies by provider)
config.retentionDays integer Days to keep this integration’s security events and daily statistics (1-365). Unset uses the deployment default, 90 days. See Data retention
config.storeAllowedTraffic boolean Also store allowed queries. Unset or false (the default) stores blocked and redirected queries only. See Stored traffic
config.categories string[] Threat categories to sync (max 100 entries)
config.blocklistId string Provider-specific blocklist identifier for policy sync
config.allowlistId string Provider-specific allowlist identifier for policy sync
isActive boolean Whether the integration is active (default true)

To immediately sync events from a provider instead of waiting for the next scheduled interval:

Terminal window
POST /dns-security/integrations/:id/sync

The response includes a jobId for tracking the sync task in BullMQ.

Each integration records the outcome of its last sync run in lastSyncStatus: success, error (with a reason in lastSyncError), partial, or truncated. Cisco Umbrella can only return a limited number of records per request, so one run makes at most 100 requests to its activity feed and saves the events in time slices as it goes. A run that uses up those requests before reaching the present stops and records partial, and lastSyncError names the time range that has not been fetched yet, for example Reached the per-run request budget; 2026-10-22T04:25:27.166Z..2026-10-24T16:00:00.187Z not yet fetched, resumes next run. lastSync then shows how far the data reaches. The dashboard marks the integration as catching up and shows that range on hover. Nothing is skipped: the next scheduled run starts where the last one stopped and keeps the slice size that worked, so a backlog left by an outage or a paused integration catches up over the following runs. When a run reaches the present, the status returns to success. Each sync covers at most the last 30 days, because the Umbrella reporting API does not return older activity.

The Umbrella activity feed also stops paging at 10,000 records per request window, so a sync narrows the window until each one fits, down to a minimum of one second. If more than about 10,000 records fall inside a single second, the records past that limit cannot be fetched at all. The run keeps what it could fetch, moves on, and records truncated. lastSyncError lists the affected windows and how many records were fetched from each, for example 1 window held more records than the provider lets a sync page through; records past that limit were not fetched and will not be retried: 2026-10-22T04:00:00.000Z..2026-10-22T04:00:01.000Z (11000 fetched). The dashboard marks the integration as some events not fetched and shows those windows on hover. Unlike partial, a later run does not recover these records. If the same run also used up its request budget, the status is truncated and lastSyncError names the not-yet-fetched range as well. The status describes the last run only: the next run that loses no records sets it back to success, or to partial if that run runs out of requests.

By default Breeze stores only blocked and redirected queries. Those are the events it acts on: threat alerts, the events list, and device context. Allowed queries are usually about 99% of a provider’s log (around a million rows a day for a busy tenant), and your provider already keeps them in its own reporting, so Breeze does not store them unless you ask it to.

To store allowed queries for an integration, open DNS Security → Integrations, click the Stored data button (the clock icon) on its row, and turn on Store allowed traffic. Or call the API:

Terminal window
PATCH /dns-security/integrations/:id
Content-Type: application/json
{ "storeAllowedTraffic": true }

The setting applies to syncs from then on. It does not fetch allowed queries for time already synced, and turning it off does not delete allowed queries that are already stored; the retention job removes them as they age out.

How each provider handles it:

Provider Where the action comes from With allowed traffic off
Cisco Umbrella Each activity record’s verdict (proxied is stored as redirected) Breeze asks Umbrella for the blocked and proxied verdicts only, so allowed records are not downloaded and do not use the per-run request budget
Cloudflare Gateway Each sampled group’s resolverDecision Allowed groups are downloaded and discarded before they are stored
DNSFilter Each record’s action / decision Downloaded and discarded before they are stored
Pi-hole (v5 and v6) Each query’s status Downloaded and discarded before they are stored
AdGuard Home Each query’s filtering reason Downloaded and discarded before they are stored

When no active integration in scope stores allowed traffic, GET /dns-security/stats returns "allowedTraffic": "none" with allowedQueries and blockedRate set to null, and the Overview tab shows Not stored instead of a zero. When only some integrations store it, the response says "some" and the counts are partial. The get_dns_security AI tool reports the same field with a short note.

The coverage follows the integrations’ current setting, not the history of the rows in the window. After you turn Store allowed traffic off, allowed rows that are still stored no longer appear in the totals, which show Not stored. After you turn it on, the allowed count covers only the time since then.

Removing allowed queries stored before this change

Section titled “Removing allowed queries stored before this change”

Breeze does not delete allowed queries that earlier versions stored. The nightly retention job removes them as they pass retentionDays. To free the space sooner, an operator can delete them by hand. Connect to the database as its owner (the API’s breeze_app role cannot see rows outside a tenant scope), then run this in batches until it reports DELETE 0:

DELETE FROM dns_security_events
WHERE ctid IN (
SELECT ctid FROM dns_security_events
WHERE action = 'allowed'
LIMIT 5000
);

Add AND integration_id = '<integration id>' to the inner query to limit it to one integration. Each statement commits on its own, so it does not hold a long lock on the table the sync writes to. Run VACUUM (ANALYZE) dns_security_events; afterwards so Postgres can reuse the space. The daily statistics in dns_event_aggregations keep their historical allowed counts until retention removes them.

A nightly job (22:18 UTC) deletes security events and daily statistics (aggregations) older than each integration’s config.retentionDays. When an integration has no retentionDays, the deployment default applies: 90 days, or the value of DNS_SECURITY_RETENTION_DAYS_DEFAULT. Values are clamped to 1–365.

To change the window on an existing integration, open DNS Security → Integrations and use the clock button on the integration’s row, or call the API:

Terminal window
PATCH /dns-security/integrations/:id
Content-Type: application/json
{ "retentionDays": 30 }

Send "retentionDays": null to clear the override and fall back to the default. This requires MFA and the orgs:write permission. Only retentionDays and storeAllowedTraffic can be changed after an integration is created.

The job deletes in batches, and each batch commits on its own, so it never holds a long lock on the tables the sync writes to. On a large backlog, the first runs after an upgrade may not reach the window in one night: the job logs a warning, reports the backlog to Sentry, and continues the next night. These environment variables control it:

Variable Default Description
DNS_SECURITY_RETENTION_DAYS_DEFAULT 90 Retention for integrations without retentionDays, in days (1–365)
DNS_SECURITY_RETENTION_BATCH_SIZE 5000 Rows deleted per batch
DNS_SECURITY_RETENTION_MAX_BATCHES 400 Maximum batches per table, per integration, per nightly run
Terminal window
DELETE /dns-security/integrations/:id

The request answers 202 Accepted with { "success": true, "id": "<id>", "status": "deleting" }. The integration stops syncing right away and is shown as Deleting…. A background job then removes its daily statistics and security events in batches, then its policies and the integration itself. On a large history this can take minutes to hours; the integration disappears from the list when it is done. Sending the same DELETE again while it is deleting re-queues the job without other effect, and the nightly retention job re-queues any delete whose job was lost.


Policies define domain-level allowlists and blocklists that are enforced through the connected DNS provider. Each policy is tied to a specific integration and organization.

Terminal window
POST /dns-security/policies
Content-Type: application/json
Authorization: Bearer <token>
{
"orgId": "uuid",
"integrationId": "uuid",
"name": "Malware Blocklist",
"description": "Block known malicious domains",
"type": "blocklist",
"domains": [
{ "domain": "malware-example.com", "reason": "Known C2 domain" },
{ "domain": "phishing-site.net", "reason": "Reported phishing" }
],
"categories": ["malware", "phishing", "botnet"],
"isActive": true
}
Field Type Required Description
orgId UUID No Organization ID. Auto-resolved for org-scoped tokens
integrationId UUID Yes The DNS filter integration this policy applies to
name string Yes Human-readable name (max 200 chars)
description string No Optional description (max 2,000 chars)
type string Yes blocklist or allowlist
domains array No Domain entries (max 500). Each with domain (required) and reason (optional)
categories string[] No Threat categories to include in this policy (max 50)
isActive boolean No Whether the policy is active (default true)

When a policy is created or updated, its syncStatus is set to pending. A BullMQ job is enqueued to push the changes to the DNS provider. The status progresses as follows:

Status Meaning
pending Changes queued, waiting for sync to the provider
synced Policy successfully pushed to the provider
error Sync failed. Check syncError for details

Add or remove domains from an existing policy without replacing the entire domain list:

Terminal window
PATCH /dns-security/policies/:id/domains
Content-Type: application/json
{
"add": [
{ "domain": "new-bad-domain.com", "reason": "Reported by threat intel feed" }
],
"remove": ["old-domain.net"]
}

At least one domain must be provided in add or remove. Domains are normalized to lowercase with trailing dots stripped. Duplicate domains are silently ignored. The response includes the updated domain count and lists of successfully added and removed domains.


DNS security events are the individual records of DNS queries processed by your DNS filter provider. Each event captures the queried domain, the action taken, the threat category (if applicable), and the source device.

Terminal window
GET /dns-security/events?start=2026-02-01T00:00:00Z&end=2026-02-15T00:00:00Z&action=blocked&category=malware&limit=50
Parameter Type Description
start ISO 8601 Start of time window (inclusive)
end ISO 8601 End of time window (inclusive)
action string Filter by action: allowed, blocked, redirected
category string Filter by threat category
domain string Partial domain match (case-insensitive)
deviceId UUID Filter events for a specific device
integrationId UUID Filter events from a specific integration
limit integer Results per page (1-500, default 100)
offset integer Pagination offset (default 0)

Each event in the response includes:

Field Type Description
id UUID Unique event identifier
orgId UUID Organization ID
integrationId UUID Source integration
deviceId UUID Linked device (nullable if device not matched)
deviceHostname string Hostname of the linked device (from join)
timestamp ISO 8601 When the DNS query occurred
domain string Queried domain name
queryType string DNS query type (e.g., A, AAAA, CNAME)
action string allowed, blocked, or redirected
category string Threat category (nullable)
threatType string Provider-specific threat classification
sourceIp string IP address of the querying device
sourceHostname string Hostname of the querying device (from provider)
providerEventId string Unique event ID from the DNS provider
metadata object Additional provider-specific data

When a blocked event arrives with a known threat category (malware, phishing, botnet, ransomware, cryptomining), Breeze automatically raises a high-severity alert for the affected device. You don’t need a rule for this — the alert fires from the DNS Security pipeline itself.

To avoid alert storms when the same device hits the same threat category repeatedly, alerts are deduplicated per (device, category) for a 60-minute cooldown window. A second malware block on the same device within the window updates the existing alert’s “last seen” timestamp rather than raising a new one.

Threat events are also published on the internal event bus so other consumers (custom webhooks, future integrations) can subscribe to dns.threat.blocked without polling the events endpoint.


Get aggregated DNS security statistics including total queries, blocked rates, top blocked domains, top categories, and top offending devices:

Terminal window
GET /dns-security/stats?start=2026-02-01T00:00:00Z&end=2026-02-15T00:00:00Z&topN=20
Parameter Type Description
start ISO 8601 Start of time window
end ISO 8601 End of time window
integrationId UUID Filter by integration
deviceId UUID Filter by device
action string Filter by action type
category string Filter by threat category
topN integer Number of top entries to return (1-100, default 10)

The response includes:

{
"summary": {
"totalQueries": 125000,
"blockedQueries": 3200,
"allowedQueries": 121500,
"redirectedQueries": 300,
"blockedRate": 2.56
},
"allowedTraffic": "all",
"topBlockedDomains": [...],
"topCategories": [...],
"topDevices": [...],
"source": "aggregated"
}

allowedTraffic says whether the active integrations in scope store allowed queries: all, some (the allowed count and blocked rate are partial), or none. With none, allowedQueries and blockedRate are null rather than a misleading zero and 100%. See Stored traffic.

Get a ranked list of the most frequently blocked domains:

Terminal window
GET /dns-security/top-blocked?start=2026-02-01T00:00:00Z&end=2026-02-15T00:00:00Z&limit=20

Each entry includes the domain, its threat category, the block count, and the number of distinct devices that attempted to reach it.


Deploying DNS filtering clients (Control D, NextDNS)

Section titled “Deploying DNS filtering clients (Control D, NextDNS)”

Besides integrating with a provider’s API, Breeze can install a vendor’s DNS filtering client on Windows devices. Two Breeze-pinned built-in packages are offered: Control D (Windows) and NextDNS CLI (Windows). Bring your own vendor account; Breeze does not resell or provision either service. Control D for macOS is not yet offered.

Package Installs to Enrolls with
Control D (Windows) C:\ControlD\ctrld.exe A provisioning code
NextDNS CLI (Windows) C:\Program Files\nextdns\nextdns.exe A profile ID

The client binaries are vendor GitHub release assets, pinned in Breeze by version and sha256. The agent verifies the sha256 when it downloads the file. Both packages need agent 0.125.0 or newer.

  1. Add the package to your catalog. Go to Software → Catalog → Built-in packages and choose Add to catalog on the card. This needs full partner access (organization access “all”), the devices.execute permission and MFA. Organization-scoped users do not see the built-in cards; the partner deploys them.
  2. Set the tenant variable per organization. Under Settings → Variables, set controld_provisioning_code (Control D) or nextdns_profile_id (NextDNS) to your <provisioning-code> or <profile-id>. These variables are non-secret by design, and secret variables are rejected.
  3. Deploy. Use a software deployment, or a partner-wide software policy with an allowlist rule so the client is installed and kept in place across organizations.

Compliance uses the service_running signal (the ctrld or nextdns service), so a stopped client is reported as not_running. See Software Policies.

Built-in rows are managed by Breeze: you cannot edit them, delete them or add versions. When a Breeze update pins a newer client version, provisioned packages move to it automatically on the next API start. This affects new installs only; devices that already have the client keep their version, and the vendor clients update themselves.

Breeze does not uninstall zip-installed clients. Run the vendor’s uninstall command on the device:

Terminal window
C:\ControlD\ctrld.exe uninstall
& "C:\Program Files\nextdns\nextdns.exe" uninstall

Control D endpoints that you delete in the Control D dashboard also uninstall themselves.


Method Path Description
GET /dns-security/integrations List all integrations for the organization
POST /dns-security/integrations Create a new DNS filter integration (requires MFA, orgs:write)
PATCH /dns-security/integrations/:id Change retentionDays and/or storeAllowedTraffic (requires MFA, orgs:write)
DELETE /dns-security/integrations/:id Delete the integration and all associated data in the background; answers 202 (requires MFA, orgs:write)
POST /dns-security/integrations/:id/sync Trigger immediate event sync from the provider
Method Path Description
GET /dns-security/events List DNS security events with filtering and pagination
Method Path Description
GET /dns-security/stats Aggregated statistics: totals, block rates, top domains/categories/devices
GET /dns-security/top-blocked Ranked list of most frequently blocked domains
Method Path Description
GET /dns-security/policies List all DNS policies for the organization
POST /dns-security/policies Create a new allowlist or blocklist policy (requires orgs:write)
PATCH /dns-security/policies/:id/domains Add or remove domains from a policy (requires orgs:write)

Integration created but events are not appearing. After creating an integration, an initial sync job is enqueued via BullMQ. If the sync scheduling fails, the creation response includes a warning field explaining the issue. Verify that Redis and BullMQ workers are running. Check the integration’s lastSyncStatus and lastSyncError fields via GET /dns-security/integrations to see if the sync completed or encountered an error. Also confirm that the API key and provider configuration are correct.

Policy sync stuck in pending status. Policy syncs are dispatched through BullMQ. If BullMQ is down or backlogged, the sync will remain in pending state. Restart the DNS sync worker and check the syncError field on the policy for specific error details. If the provider API returns an authentication error, verify the integration’s API credentials.

Policy changes left pending by an earlier release are not pushed automatically after upgrading: only new edits queue a sync, and an edit pushes just its own additions and removals. To push domains that were pending before the upgrade, remove them from the policy and add them again (or re-create the policy).

Sync error: “Host resolves to a private/blocked address”. The integration’s host (for example a Pi-hole address) resolves to a private or blocked network address that the Breeze server will not connect to. The integration’s lastSyncError (or the policy’s syncError) shows this message and the sync stops there. Use a host the Breeze server can reach on a public address.

Stats endpoint returning unexpected results. For time windows longer than 7 days, statistics are sourced from the dns_event_aggregations table rather than raw events. Aggregations are computed periodically and may lag behind real-time data. The source field in the response (raw or aggregated) indicates which data source was used. For the most current data over short periods, use a time window of 7 days or less.

Time range query rejected with 400 error. The maximum query window for events and statistics is 90 days. If your start-to-end range exceeds this limit, narrow the time range. Also verify that both start and end are valid ISO 8601 timestamps and that start is before end.

Domains not blocking after adding to a blocklist policy. After adding domains to a policy, verify the syncStatus is synced (not pending or error). If the sync failed, check the syncError field. Domains are normalized to lowercase with trailing dots removed before storage and sync. If the provider requires a specific domain format, verify it matches the normalized form. Note that DNS caching at the client or resolver level may delay enforcement of new rules.

MFA required error when creating or deleting integrations. Creating, editing and deleting DNS filter integrations require MFA verification. Ensure the authenticated user has completed MFA for the current session. The requireMfa() middleware returns a 403 if MFA has not been verified.

Pi-hole v6 sync fails with HTTP 429 and no_seats. Pi-hole v6’s admin API is session-based and only allows a limited number of concurrent API sessions (“seats”). If a sync attempt fails with HTTP 429 and an error body containing no_seats, all available session seats are currently in use — this is not a generic rate limit, so retrying immediately usually won’t help. This typically happens when other clients (Pi-hole’s own admin web UI, another integration, or a stale session that hasn’t expired yet) are holding the remaining seats. Wait for existing sessions to expire, close other concurrent connections to the Pi-hole API, and then retry the sync. If this happens repeatedly, consider reducing how many other tools or browser tabs stay logged into the Pi-hole admin interface at the same time.