Indie Web Security 2026: Observations from Submitted Domains
A historical snapshot of visible security controls: 616 submitted domains, observed between 2026-05-03 and 2026-09-03.
of observed homepage records had no Content Security Policy recorded.
406 of 614 records in the submitted-domain sample
had neither SPF nor DMARC recorded, and no positive DKIM detection recorded.
155 of 584 DNS-observed records; unknown DKIM is not proven absence
had a historical high or critical classification in the coverage-selected sample.
43 of 77 domains; not independently confirmed exploits
DMARC detection in exploratory public-tier groups, with and without a builder fingerprint.
29/36 and 240/460; scan dates and historical methods vary
domains had a non-informational token finding. Credential validity was not verified.
12 other domains had informational public-key or configuration observations
Executive summary
UNPWNED recorded external security observations for 616 distinct domains submitted for scanning during this window. The sample is self-selected. Team size, company size and use of AI coding assistants were not established, so these results describe the submitted domains rather than the prevalence of vulnerabilities across all indie apps.
A Content Security Policy was not recorded on 406 of 614 homepage responses (66%). SPF and DMARC were not recorded, and no positive DKIM discovery was recorded, for 155 of 584 domains with a recorded DNS result (27%). This does not establish that those domains use no DKIM. A security.txt file was recorded on 41 of 611 domains; other reporting channels were not measured.
In the separately selected 77-domain sample with no recorded coverage-incomplete flag, 43 domains had a historical high or critical scanner label. These labels reflect the rules in use when their scans ran. They are not counts of independently confirmed exploitable vulnerabilities.
The public-tier records show differences in DMARC and CSP detection between sites with an AI builder fingerprint and sites without one. Different scan dates, changing methods, hosting choices and a concentration in Lovable prevent attributing these differences to AI or to platform defaults.
Methodology
Population and unit. The source is scan telemetry marked research_eligible = true. A domain is the stored research-domain key, falling back to the detected host. Each sample independently selects one eligible scan per domain within the UTC window from 2026-05-03 inclusive to 2026-09-04 exclusive. These are distinct domain keys, not necessarily independent organizations or applications.
- Sample A, recorded controls, n = 616. Select the latest eligible scan per domain. Its recorded submission tiers are 536 public-checker submissions, 30 verified-domain submissions and 50 partner submissions. A submitter declaration is not independent proof of ownership.
- Sample B, coverage-selected records, n = 77. First restrict to scans with
coverage_incomplete = false, then select the latest eligible scan per domain. This can select an older scan than sample A. Selecting the latest scan first and then checking coverage would yield 72 domains, not 77; five selected records differ. The 77 comprise 46 partner submissions and 31 verified-domain submissions, including 17 deep scans. - Public-tier exploratory sample, n = 549. First restrict to the public submission tier, then select its latest eligible scan per domain. It contains 40 detected-builder domains, 488 without a detected builder and 21 unconfirmed self-reported candidates. It differs from the 536 public-tier records selected in sample A.
Coverage is bounded. The stored coverage flag does not mean that every possible test ran or every result was conclusive. Five of the 77 selected records also have a positive incomplete-scanner count. Standard and deep scans have different scopes; checks can be inapplicable or unable to discover a backend. Public profiles and scanner rules changed during the window. A shared submission tier does not establish an identical measurement procedure.
Denominators. Each control uses the number of records with a usable value for that field. DKIM is the exception: the historical field records a positive discovery or null, so its denominator is records with an SPF value, used as the available DNS-observation proxy. Null DKIM means no positive record, not proven absence. The combined email metrics use this same proxy. Missing telemetry is excluded from the relevant denominator and is not automatically counted as missing protection.
TLS has 538 recorded grades, privacy-page detection 425 results, DNS 584, homepage CSP 614, and security.txt 611. Public-profile changes and provider-managed DNS handling contribute to these differences; a null field alone does not identify the exact reason for every missing observation.
Finding definitions. Severity totals from telemetry exclude informational findings. The common-finding table includes informational entries. Secret-category findings above informational severity are reported as scanner flags requiring validation; severity alone does not prove a working credential. Informational secret-category records are shown separately. Port and API signals do not by themselves establish unauthorized access, data sensitivity or who controls shared infrastructure.
Historical methods. The snapshot spans multiple scoring-methodology versions and rule changes. Methodology versions were not recorded for 420 of 616 sample-A records and 55 of 77 sample-B records. Before mid-August, the processing pipeline could also supplement scanner findings with AI-generated entries. A July correction reduced the severity assigned to permissive CSP combinations. Historical grades and severity counts are retained as recorded, and are not used to rank builder security or estimate current critical-vulnerability prevalence.
The download contains frozen aggregate counts and interpretation notes. Percentages are computed from those counts. The queries below return aggregates; reproducing them requires access to the underlying private records. The public download contains no sampled domain names, account identifiers, scan identifiers or secret values.
Aggregate selection and reproduction queries
Sample A: recorded controls and per-field denominators
with latest as (
select distinct on (coalesce(research_domain, detected_host)) *
from public.scan_telemetry
where research_eligible = true
and created_at >= timestamptz '2026-05-03 00:00:00+00'
and created_at < timestamptz '2026-09-04 00:00:00+00'
order by coalesce(research_domain, detected_host), created_at desc, id desc
)
select count(*) as n,
count(*) filter (where ssl_grade is not null) as tls_observed,
count(*) filter (where ssl_grade in ('A','A+')) as tls_a,
count(*) filter (where has_csp is not null) as csp_observed,
count(*) filter (where has_csp) as csp,
count(*) filter (where has_spf is not null) as spf_observed,
count(*) filter (where has_spf) as spf,
count(*) filter (where has_dmarc is not null) as dmarc_observed,
count(*) filter (where has_dmarc) as dmarc,
count(*) filter (where has_spf is not null) as dkim_dns_proxy_observed,
count(*) filter (where has_spf is not null and has_dkim is true) as dkim_positive,
count(*) filter (where has_spf is true and has_dmarc is true and has_dkim is true) as all_three_email_signals,
count(*) filter (where has_spf is false and has_dmarc is false and has_dkim is not true) as no_email_signals_recorded,
count(*) filter (where has_dnssec is not null) as dnssec_observed,
count(*) filter (where has_dnssec) as dnssec,
count(*) filter (where has_security_txt is not null) as security_txt_observed,
count(*) filter (where has_security_txt) as security_txt,
count(*) filter (where has_privacy_policy is not null) as privacy_observed,
count(*) filter (where has_privacy_policy) as privacy_policy,
count(*) filter (where has_consent_mechanism is not null) as consent_observed,
count(*) filter (where has_consent_mechanism) as consent,
count(*) filter (where missing_headers_count is not null) as headers_observed,
percentile_cont(0.5) within group (order by missing_headers_count) as missing_headers_median,
count(*) filter (where missing_headers_count = 0) as zero_missing_headers,
count(*) filter (where missing_headers_count >= 3) as three_plus_missing_headers
from latest;Sample B: latest eligible coverage-selected records, historical severities and totals
with latest as (
select distinct on (coalesce(research_domain, detected_host)) *
from public.scan_telemetry
where research_eligible = true
and created_at >= timestamptz '2026-05-03 00:00:00+00'
and created_at < timestamptz '2026-09-04 00:00:00+00'
and coverage_incomplete = false
order by coalesce(research_domain, detected_host), created_at desc, id desc
)
select count(*) as n,
count(*) filter (where critical_count > 0) as recorded_critical,
count(*) filter (where high_count > 0 or critical_count > 0) as recorded_high_or_critical,
count(*) filter (where total_findings = 0) as zero_non_informational_findings,
sum(total_findings) as non_informational_findings,
(select count(*) from public.findings f join latest l on l.scan_id = f.scan_id where f.severity = 'info') as informational_findings,
(select count(*) from public.findings f join latest l on l.scan_id = f.scan_id) as all_findings,
percentile_cont(0.5) within group (order by security_score) as historical_median_score
from latest;Sample B: abbreviated display labels mapped to stored title groups
with latest as (
select distinct on (coalesce(research_domain, detected_host)) *
from public.scan_telemetry
where research_eligible = true
and created_at >= timestamptz '2026-05-03 00:00:00+00'
and created_at < timestamptz '2026-09-04 00:00:00+00'
and coverage_incomplete = false
order by coalesce(research_domain, detected_host), created_at desc, id desc
), display_labels(label, stored_title, severity, category) as (
values
('No security.txt vulnerability disclosure file', 'Missing security.txt vulnerability disclosure file', 'low', 'Vulnerability Disclosure'),
('DNSSEC not enabled', 'DNSSEC not enabled', 'medium', 'DNS & Email Security'),
('Missing Permissions-Policy header', 'Missing security header: Permissions-Policy', 'info', 'Security Headers'),
('No Content Security Policy', 'Missing Content Security Policy', 'medium', 'Security Headers'),
('No cookie consent mechanism detected', 'No cookie consent mechanism detected', 'info', 'Privacy & Compliance'),
('Missing X-Frame-Options header', 'Missing security header: X-Frame-Options', 'low', 'Security Headers'),
('Missing Referrer-Policy header', 'Missing security header: Referrer-Policy', 'low', 'Security Headers'),
('DMARC deployed but not enforcing (p=none)', 'DMARC deployed but not enforcing (p=none)', 'low', 'DNS & Email Security'),
('Missing X-Content-Type-Options header', 'Missing security header: X-Content-Type-Options', 'low', 'Security Headers'),
('No CSP violation reporting', 'Missing CSP violation reporting', 'low', 'Security Headers'),
('Missing Strict-Transport-Security header', 'Missing security header: Strict-Transport-Security', 'medium', 'Security Headers'),
('No DMARC record', 'Missing DMARC record', 'high', 'DNS & Email Security')
), selected_findings as (
select coalesce(l.research_domain, l.detected_host) as domain_key,
f.title, f.severity, f.category
from public.findings f join latest l on l.scan_id = f.scan_id
)
select d.label, d.severity, d.category,
count(distinct f.domain_key) as domains
from display_labels d left join selected_findings f
on lower(f.title) = lower(d.stored_title) and f.severity = d.severity and f.category = d.category
group by d.label, d.severity, d.category
order by domains desc, d.label, d.severity, d.category;Sample B: secret-category classifications, without credential values
with latest as (
select distinct on (coalesce(research_domain, detected_host)) *
from public.scan_telemetry
where research_eligible = true
and created_at >= timestamptz '2026-05-03 00:00:00+00'
and created_at < timestamptz '2026-09-04 00:00:00+00'
and coverage_incomplete = false
order by coalesce(research_domain, detected_host), created_at desc, id desc
), per_domain as (
select coalesce(l.research_domain, l.detected_host) as domain_key,
bool_or(f.severity in ('low','medium','high','critical')) as above_info,
bool_or(f.severity = 'info') as informational
from public.findings f join latest l on l.scan_id = f.scan_id
where f.category ilike '%secret%'
group by coalesce(l.research_domain, l.detected_host)
)
select count(*) as domains_with_secret_category,
count(*) filter (where above_info) as above_info,
count(*) filter (where informational and not above_info) as info_only,
count(*) filter (where informational and above_info) as overlap
from per_domain;Exploratory public-tier observations, with no severity ranking
with latest as (
select distinct on (coalesce(research_domain, detected_host)) *
from public.scan_telemetry
where research_eligible = true
and created_at >= timestamptz '2026-05-03 00:00:00+00'
and created_at < timestamptz '2026-09-04 00:00:00+00'
and research_trust_tier = 'public_authorized'
order by coalesce(research_domain, detected_host), created_at desc, id desc
), grouped as (
select *, case when ai_builder_label_quality in ('gold','silver') then 'ai_builder'
when ai_builder_label_quality = 'candidate' then 'self_reported'
else 'no_builder_detected' end as builder_group
from latest
)
select builder_group, count(*) as n,
count(*) filter (where ssl_grade is not null) as tls_observed,
count(*) filter (where ssl_grade in ('A','A+')) as tls_a,
count(*) filter (where has_csp is not null) as csp_observed,
count(*) filter (where has_csp) as csp,
count(*) filter (where has_spf is not null) as spf_observed,
count(*) filter (where has_spf) as spf,
count(*) filter (where has_dmarc is not null) as dmarc_observed,
count(*) filter (where has_dmarc) as dmarc,
count(*) filter (where has_spf is not null) as dkim_dns_proxy_observed,
count(*) filter (where has_spf is not null and has_dkim is true) as dkim_positive,
count(*) filter (where headers_detail is not null) as headers_observed,
count(*) filter (where headers_detail is not null and not (headers_detail->>'missing' ilike '%strict-transport-security%')) as hsts,
count(*) filter (where missing_headers_count = 0) as zero_missing_headers,
count(*) filter (where has_supabase is not null) as supabase_observed,
count(*) filter (where has_supabase) as supabase
from grouped group by builder_group order by builder_group;Sample A: recorded methodology versions
with latest as (
select distinct on (coalesce(research_domain, detected_host)) *
from public.scan_telemetry
where research_eligible = true
and created_at >= timestamptz '2026-05-03 00:00:00+00'
and created_at < timestamptz '2026-09-04 00:00:00+00'
order by coalesce(research_domain, detected_host), created_at desc, id desc
)
select coalesce(methodology_version, 'unrecorded') as methodology, count(*) as domains
from latest group by methodology_version order by methodology;Recorded security controls
521 of 538 recorded TLS grades were A or A+ (97%). This describes the scanner's transport checks; it does not establish application security. Other controls were recorded less often.
Table view
| Measure | Share | Of |
|---|---|---|
| TLS grade A or A+ | 97% | 521 of 538 |
| Privacy page detected | 78% | 332 of 425 |
| SPF record detected | 62% | 364 of 584 |
| DMARC record detected | 55% | 320 of 584 |
| DKIM key found on common selectors | 46% | 270 of 584 |
| Content Security Policy detected | 34% | 208 of 614 |
| SPF, DMARC and common-selector DKIM detected | 32% | 188 of 584 |
| Consent mechanism detected | 16% | 68 of 425 |
| DNSSEC recorded | 9% | 53 of 584 |
| security.txt detected | 7% | 41 of 611 |
Finding SPF, DKIM or DMARC records does not establish successful authentication of actual mail, DMARC alignment or an enforcing policy. Conversely, a DKIM selector that was not tested can conceal a valid configuration from these checks.
The median recorded homepage was missing 4 headers in the scanner's checklist. 382 of 614 records lacked three or more, while 122 lacked none. Header applicability and alternative controls need application-specific review; the count is not a count of exploitable vulnerabilities.
Common scanner findings
Sample B records 571 non-informational findings in telemetry, averaging 7.4 per domain. The chart below separately lists common stored title, severity and category groups, including informational recommendations. Historical naming variants are not merged, so a displayed group can undercount the broader issue family. A finding title describes the scanner's classification.
Table view
| Measure | Share | Of |
|---|---|---|
| No security.txt vulnerability disclosure file | 86% | 66 of 77 |
| DNSSEC not enabled | 74% | 57 of 77 |
| Missing Permissions-Policy header | 55% | 42 of 77 |
| No Content Security Policy | 49% | 38 of 77 |
| No cookie consent mechanism detected | 42% | 32 of 77 |
| Missing X-Frame-Options header | 40% | 31 of 77 |
| Missing Referrer-Policy header | 39% | 30 of 77 |
| DMARC deployed but not enforcing (p=none) | 38% | 29 of 77 |
| Missing X-Content-Type-Options header | 35% | 27 of 77 |
| No CSP violation reporting | 29% | 22 of 77 |
| Missing Strict-Transport-Security header | 25% | 19 of 77 |
| No DMARC record | 18% | 14 of 77 |
A missing security.txt file means the standardized disclosure file was not found; it does not mean the organization has no contact channel. A DMARC policy of p=none requests monitoring rather than quarantine or rejection under DMARC, but does not remove other mail-provider protections.
Historical severity and exposure signals
43 of 77 domains (56%) had a high or critical label in their selected telemetry record; 9 had a critical label. 1 had no non-informational findings in telemetry. Informational findings and unobserved risks can still exist.
Table view
| Grade | Domains | Share |
|---|---|---|
| A+ | 2 | 3% |
| A | 31 | 40% |
| B | 19 | 25% |
| C | 14 | 18% |
| D | 3 | 4% |
| F | 8 | 10% |
Table view
| Measure | Count | Of |
|---|---|---|
| Historical CSP-related classifications | 8 | 8 of 9 |
| Known CVEs in an outdated CMS | 1 | 1 of 9 |
8 of the 9 critical labels concerned CSP. Seven mentioned both 'unsafe-inline' and 'unsafe-eval'. The eighth concerned inline scripts. These permissions can weaken script defenses, but their effect depends on the entire policy and application. Nonces or hashes can cause browsers to ignore 'unsafe-inline', and other directives can still protect resources. This combination alone does not prove XSS, make every directive ineffective or establish a critical exploit. The remaining label concerned a detected CMS version associated with published CVEs, without exploitation verification.
Table view
| Measure | Count | Of |
|---|---|---|
| DNS and email | 17 | 17 of 77 |
| Security headers | 17 | 17 of 77 |
| Open ports | 4 | 4 of 77 |
| API security | 2 | 2 of 77 |
| Sensitive data exposure | 2 | 2 of 77 |
| Other | 5 | 5 of 77 |
Detected technology
Next.js and Vercel were the most frequently recorded framework and host labels. Technology fingerprints can be incomplete or ambiguous. A missing fingerprint does not establish that a site is static, custom-built or free of that technology.
Table view
| Measure | Count | Of |
|---|---|---|
| Next.js | 103 | 103 of 616 |
| WordPress | 43 | 43 of 616 |
| React | 34 | 34 of 616 |
| Express.js | 15 | 15 of 616 |
| Lovable | 14 | 14 of 616 |
| Angular | 12 | 12 of 616 |
| Vite | 11 | 11 of 616 |
| ASP.NET | 11 | 11 of 616 |
| jQuery | 10 | 10 of 616 |
| Astro | 9 | 9 of 616 |
| Shopify | 9 | 9 of 616 |
Table view
| Measure | Count | Of |
|---|---|---|
| Vercel | 145 | 145 of 616 |
| Cloudflare Pages | 99 | 99 of 616 |
| Render | 36 | 36 of 616 |
| Netlify | 28 | 28 of 616 |
| Railway | 7 | 7 of 616 |
| AWS | 6 | 6 of 616 |
| GitHub Pages | 6 | 6 of 616 |
Cloudflare was recorded as the CDN on 226 of 616 domains. Supabase was positively recorded on 78 of 530 records with a non-null backend-detection field. Three non-null records lack a successful scanner status, so this is a stored-field denominator rather than a verified-completion count. These labels are descriptive and are not provider security rankings.
Exploratory builder observations
Across sample A, 45 domains had a gold- or silver-confidence builder fingerprint: Lovable 39, Base44 5, v0 1. Another 23 were unconfirmed candidates. The concentration in Lovable limits any generalization to other builders.
The following separate public-tier sample contains 40 domains with a detected builder and 488 without one. These groups share a submission tier, but are not matched by scan date, method version, hosting, domain age or application type. Their rates are exploratory descriptions, with no claim of a causal effect or representative population difference.
Table view
| Measure | AI app builder detected | No builder detected |
|---|---|---|
| HSTS recorded | 100% (40/40) | 63% (305/486) |
| TLS grade A or A+ | 100% (36/36) | 97% (405/419) |
| DMARC record detected | 81% (29/36) | 52% (240/460) |
| SPF record detected | 69% (25/36) | 60% (278/460) |
| DKIM key found on common selectors | 31% (11/36) | 46% (210/460) |
| Content Security Policy detected | 15% (6/40) | 36% (175/486) |
| No missing checklist headers | 8% (3/40) | 21% (101/486) |
| Supabase detected | 54% (19/35) | 9% (39/413) |
DMARC was recorded on 29 of 36 observed builder domains and 240 of 460 other domains. CSP was recorded on 6 of 40 and 175 of 486, respectively. A record's presence does not establish effective enforcement.
The 21 unconfirmed candidates are kept out of the paired chart. Their CSP count is 1 of 21, and their DMARC count is 6 of 21. These small counts are disclosed without ranking or comparative conclusions.
Recommendations for developers
- Review CSP in application context. Test a policy in report-only mode, use suitable nonces or hashes, and reduce unnecessary script permissions. Check the effective policy before assigning risk; adding a header alone is not validation.
- Validate real mail flows. Configure SPF and DKIM for legitimate senders, inspect DMARC alignment and reports, then choose an enforcement policy. Check your actual DKIM selector rather than relying on common-selector discovery.
- Review applicable headers. Assess HSTS, MIME sniffing protection, referrer handling, framing restrictions and permissions against the application's requirements. Test changes before deployment.
- Provide a disclosure channel. Publish a valid security.txt file with maintained contact and expiry fields, alongside any existing reporting process.
- Test access boundaries directly. Verify authorization and database policies with controlled accounts and owned test data. External backend discovery cannot certify tenant isolation.
- Classify keys before reacting. Public client keys and privileged credentials have different roles. If a privileged credential is exposed, revoke or rotate it and review its use. A scanner flag alone does not prove the credential works.
- Validate service and CORS exposure. Confirm that observed ports belong to your infrastructure, that services need to be public, and that cross-origin access cannot disclose protected data.
- Review tracking behavior and applicable requirements. Verify which trackers actually execute, what they collect and how consent or other choices work for the relevant users and jurisdictions. A detected script or absent consent widget alone is not a legal-compliance determination.
Limitations
- Selection and dependence. This is a self-selected sample of domain keys. It does not establish an indie-only population, unique organizations or statistical independence.
- Partner concentration. 46 of 77 records in sample B came through one partner program.
- Changing methods. Profiles, rule severity and detection behavior changed during the four-month window. Historical labels cannot support a current security ranking or an unadjusted trend.
- Incomplete observation. Missing telemetry, provider-managed DNS, custom DKIM selectors and conditional backend checks limit the meaning of negative findings.
- External surface. No comprehensive source review, authenticated testing or proof of absence of vulnerabilities is provided by this report.
- Builder attribution. Fingerprints identify some platforms, not the use of all AI tools. Small, concentrated groups do not support broad claims about AI development.
- Stored snapshot. Aggregate counts preserve the observation window. Subsequent corrections or deletions in source records can affect later reproduction; revisions must document such differences.
What the evidence supports
In these submitted domains, the scanner frequently recorded gaps in visible configuration controls, including CSP and email-authentication signals. The practical next step is to inspect and validate those controls in the context of each application.
The data also raises a testable question about platform defaults. Answering it requires a prospectively defined sample, stable measurement methods and control for hosting and other differences. This edition supplies descriptive observations, not a verdict on the security of AI-generated code.
UNPWNED (2026). Indie Web Security 2026: Observations from Submitted Domains. https://www.unpwned.io/research/state-of-indie-web-security-2026 Observation window ends 2026-09-03. Revised 2026-09-06. Licensed CC BY 4.0. Corrections: [email protected]
Technical references
These references support the interpretation of the controls, not the sample counts. Reviewed 2026-09-06.
