Skip to main content
UNPWNED
UNPWNED research · Published 2026-09-04 · Revised 2026-09-06

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.

Observation window ends 2026-09-03616 domains · 77 in the coverage-selected sampleAggregate dataset (JSON)Methodology
66%

of observed homepage records had no Content Security Policy recorded.

406 of 614 records in the submitted-domain sample

27%

had neither SPF nor DMARC recorded, and no positive DKIM detection recorded.

155 of 584 DNS-observed records; unknown DKIM is not proven absence

56%

had a historical high or critical classification in the coverage-selected sample.

43 of 77 domains; not independently confirmed exploits

81% vs 52%

DMARC detection in exploratory public-tier groups, with and without a builder fingerprint.

29/36 and 240/460; scan dates and historical methods vary

1 of 77

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.

Recorded controls in sample AEach rate uses its recorded observation denominator. Presence is not a test of effectiveness or legal compliance.
Table view
Recorded controls in sample A
MeasureShareOf
TLS grade A or A+97%521 of 538
Privacy page detected78%332 of 425
SPF record detected62%364 of 584
DMARC record detected55%320 of 584
DKIM key found on common selectors46%270 of 584
Content Security Policy detected34%208 of 614
SPF, DMARC and common-selector DKIM detected32%188 of 584
Consent mechanism detected16%68 of 425
DNSSEC recorded9%53 of 584
security.txt detected7%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.

Common recorded findingsSample B, 77 selected domains. Includes informational findings; labels can reflect older rules.
Table view
Common recorded findings
MeasureShareOf
No security.txt vulnerability disclosure file86%66 of 77
DNSSEC not enabled74%57 of 77
Missing Permissions-Policy header55%42 of 77
No Content Security Policy49%38 of 77
No cookie consent mechanism detected42%32 of 77
Missing X-Frame-Options header40%31 of 77
Missing Referrer-Policy header39%30 of 77
DMARC deployed but not enforcing (p=none)38%29 of 77
Missing X-Content-Type-Options header35%27 of 77
No CSP violation reporting29%22 of 77
Missing Strict-Transport-Security header25%19 of 77
No DMARC record18%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.

Recorded historical gradesSample B, 77 domains; median 85, average 79.9. Mixed historical scoring methods.
Table view
Recorded historical grades
GradeDomainsShare
A+23%
A3140%
B1925%
C1418%
D34%
F810%
Recorded critical labels by causeHistorical labels on 9 domains; not independently validated critical exploits.
Table view
Recorded critical labels by cause
MeasureCountOf
Historical CSP-related classifications88 of 9
Known CVEs in an outdated CMS11 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.

Recorded high labels by categoryDomains affected in sample B, of 77; categories overlap.
Table view
Recorded high labels by category
MeasureCountOf
DNS and email1717 of 77
Security headers1717 of 77
Open ports44 of 77
API security22 of 77
Sensitive data exposure22 of 77
Other55 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.

Recorded framework labelsSelected labels among 616 domains; 296 had no framework label.
Table view
Recorded framework labels
MeasureCountOf
Next.js103103 of 616
WordPress4343 of 616
React3434 of 616
Express.js1515 of 616
Lovable1414 of 616
Angular1212 of 616
Vite1111 of 616
ASP.NET1111 of 616
jQuery1010 of 616
Astro99 of 616
Shopify99 of 616
Recorded hosting labelsSelected labels among 616 domains; 282 had no hosting label.
Table view
Recorded hosting labels
MeasureCountOf
Vercel145145 of 616
Cloudflare Pages9999 of 616
Render3636 of 616
Netlify2828 of 616
Railway77 of 616
AWS66 of 616
GitHub Pages66 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.

Recorded controls by builder detectionPublic-tier records: 40 detected-builder domains and 488 without a detected builder. Historical methods vary; denominators reflect stored fields, not universally completed tests.
Table view
Recorded controls by builder detection
MeasureAI app builder detectedNo builder detected
HSTS recorded100% (40/40)63% (305/486)
TLS grade A or A+100% (36/36)97% (405/419)
DMARC record detected81% (29/36)52% (240/460)
SPF record detected69% (25/36)60% (278/460)
DKIM key found on common selectors31% (11/36)46% (210/460)
Content Security Policy detected15% (6/40)36% (175/486)
No missing checklist headers8% (3/40)21% (101/486)
Supabase detected54% (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

  1. 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.
  2. 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.
  3. Review applicable headers. Assess HSTS, MIME sniffing protection, referrer handling, framing restrictions and permissions against the application's requirements. Test changes before deployment.
  4. Provide a disclosure channel. Publish a valid security.txt file with maintained contact and expiry fields, alongside any existing reporting process.
  5. Test access boundaries directly. Verify authorization and database policies with controlled accounts and owned test data. External backend discovery cannot certify tenant isolation.
  6. 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.
  7. 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.
  8. 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.

Cite this report
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: support@unpwned.io

Technical references

These references support the interpretation of the controls, not the sample counts. Reviewed 2026-09-06.