The State of Indie Web App Security 2026
What real-world web apps look like from the outside: 616 domains scanned between 2026-05-03 and 2026-09-03, one row per domain, nothing identifying anyone.
of indie web apps ship without a Content Security Policy.
406 of 614 domains whose homepage answered
have no email authentication at all: no SPF, no DMARC, and no DKIM key on a common selector.
155 of 584 domains with a DNS result; only 32% have all three
of fully scanned apps carry at least one high or critical finding.
43 of 77 domains with complete coverage
DMARC on apps built with an AI app builder, against everything else. The builders lead on email, and trail on CSP.
36 builder-made domains with a DNS result vs 460, same scan profile
fully scanned apps leaked a real secret. Twelve more carried public keys that are meant to be public, which other reports count as leaks.
13 sites with a secrets finding: 1 real, 12 expected public keys
Executive summary
UNPWNED scans production websites from the outside, the way an attacker or a search engine sees them. Between 2026-05-03 and 2026-09-03 it scanned 616 distinct domains that were submitted for a scan and whose results are eligible for research. This report is what that population looks like, measured, not surveyed.
The picture is not one of exotic vulnerabilities. It is one of hygiene left on the table. 66% of the domains have no Content Security Policy. 27% have no email authentication of any kind, and only 32% have all three of SPF, DKIM and DMARC. 7% publish a security.txt file, so on nine sites out of ten a researcher who finds a problem has nowhere to report it. The median site is missing 4 of the security headers we check.
Among the 77 domains where every check completed, 56% carry at least one high or critical finding and 12% carry a critical one. Almost every critical is the same mistake: a Content Security Policy that allows both inline and evaluated scripts, which is a policy in name only.
The surprise is the AI app builders. On the same scan profile, sites built with a detected AI app builder have DMARC 81% of the time against 52% for everything else, and HSTS on every single one. They also have a Content Security Policy less than half as often (15% against 36%). The pattern is consistent with controls a platform sets once for everyone, and gaps exactly where a developer has to decide.
One more number cuts against the usual story. The secrets check raised a finding on 13 of 77 fully scanned apps. On 12 of them it was a public key that is designed to be public. On 1 it was a real secret. Counting the public keys as leaks would have made a better headline and a worse report.
Methodology
What a row is. One row per domain: the latest scan of that domain that the research pipeline marked eligible. Eligibility is decided at scan time and excludes test domains, scans without an authorization basis, unsupported sources and incomplete scans. It begins on 2026-05-03. Counting domains rather than scans removes the same site scanned many times, which is common: 616 domains produced far more scans than that. 536 of the 616 were submitted through the public checker, where the person submitting declares they are entitled to scan the site; 30 came from verified owners and 50 through a partner programme.
Two strata, because coverage decides what is comparable. Not every scan runs the same checks. An anonymous scan of a site runs a bounded set of external checks; a scan by the verified owner or through a partner programme runs the full surface set, including probes for exposed secrets, API routes, open ports and database rules. Mixing the two would manufacture findings out of coverage differences, so the report keeps them apart.
- Stratum A, hygiene, n = 616. Every eligible domain. Adoption rates in this report come from this stratum: HTTP security headers, SPF, DKIM, DMARC, DNSSEC, security.txt, TLS grade, and privacy page and consent detection. Exposure findings never do.
- Stratum B, full coverage, n = 77. Domains whose latest eligible scan completed every check in its profile. Finding counts, severities and exposures come from here, with one exception named below: the AI app builder comparison applies the same-profile rule inside the public tier. 46 of the 77 came through one partner programme and 31 through verified owners; 17 were deep scans.
A control that was not measured is never counted as missing. Every adoption rate is computed over the domains whose check actually produced a result, and that number is printed beside every bar. The denominators differ for known reasons: TLS grading and the privacy-page and consent detection are not part of the anonymous public scan profile since 2026-08-15, so they answered for 538 and 425 domains; the DNS checks skip 32 domains whose DNS is provider-managed, leaving 584; a handful of homepages never answered the header or security.txt request. DKIM is reported as “a key found on a common selector”, because a key on a custom selector is invisible from the outside.
The AI app builder comparison is restricted to the public tier (40 against 488, with 21 self-reported and unconfirmed kept separate and not used for claims), so both groups ran the same scan profile; a check can still fail to answer on an individual site, so each measure again carries its own observed denominator on both sides. Each domain contributes its latest public-tier scan, so the three groups together cover the 549 domains that have one, a slightly larger set than the 536 whose latest scan of any kind was public-tier. “AI app builder detected” means a builder fingerprint (Lovable, Base44, v0) at gold or silver confidence. “No builder detected” is everything the detector did not attribute. It is not “human-built”, and the report never calls it that.
Small groups are shown, not compared. Counts below thirty (individual builders, individual frameworks, the self-reported group) appear as counts for transparency. Comparative claims in this report rest only on groups of forty or more.
Definitions that change the numbers. A secret exposure counts only findings above informational severity; public keys that are meant to be public are reported separately as expected. Unexpected public ports are finding-based (a port other than 80 or 443 answering), not a raw count that includes 80 and 443. High and critical use the severity the deterministic finding engine assigned at scan time, and the same rule can carry a different severity in a different context: a missing DMARC record is high on a domain that sends mail and low on one that does not.
Every number in this report is derived at render time from stored counts, the queries are published below, and the dataset is downloadable as JSON above. There are no domains, users or scan identifiers anywhere in it.
The queries behind the numbers
Stratum A: one row per domain, each control over the domains its check answered for
with latest as (
select distinct on (coalesce(research_domain, detected_host)) *
from public.scan_telemetry
where research_eligible = true and created_at < '2026-09-04'
order by coalesce(research_domain, detected_host), created_at 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 dns_observed,
count(*) filter (where has_spf) as spf,
count(*) filter (where has_dmarc) as dmarc,
count(*) filter (where has_dkim) as dkim_found_on_common_selectors,
count(*) filter (where has_spf and has_dmarc and has_dkim) as all_three_email,
count(*) filter (where has_spf is not null and not coalesce(has_spf,false) and not coalesce(has_dmarc,false) and not coalesce(has_dkim,false)) as no_email_auth,
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) 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;Stratum B: domains with complete coverage, severities and findings
with latest as ( select distinct on (coalesce(research_domain, detected_host)) * from public.scan_telemetry where research_eligible = true and coalesce(coverage_incomplete,false) = false and created_at < '2026-09-04' order by coalesce(research_domain, detected_host), created_at desc ) select count(*) as n, count(*) filter (where critical_count > 0) as with_critical, count(*) filter (where high_count > 0 or critical_count > 0) as with_high_or_critical, count(*) filter (where total_findings = 0) as clean, percentile_cont(0.5) within group (order by security_score) as median_score from latest; -- most common findings, by domains affected select left(f.title, 80) as title, f.severity, count(distinct coalesce(l.research_domain, l.detected_host)) as domains from public.findings f join latest l on l.scan_id = f.scan_id group by 1, 2 order by domains desc; -- secrets: a real exposure is any secrets finding above informational severity; -- an informational secrets finding is a public key that is meant to be public select bool_or(f.severity <> 'info') as has_real, bool_or(f.severity = 'info') as has_public_key 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);
AI app builder comparison, public tier only, observed denominators per control
with latest as (
select distinct on (coalesce(research_domain, detected_host)) *
from public.scan_telemetry
where research_eligible = true and research_trust_tier = 'public_authorized' and created_at < '2026-09-04'
order by coalesce(research_domain, detected_host), created_at desc
),
grp 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 g
from latest
)
select g, 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 dns_observed, count(*) filter (where has_spf) as spf,
count(*) filter (where has_dmarc) as dmarc, count(*) filter (where has_dkim) as dkim,
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 high_count > 0 or critical_count > 0) as high_or_critical,
count(*) filter (where has_supabase is not null) as supabase_observed, count(*) filter (where has_supabase) as supabase
from grp group by g;The overall landscape
Transport security is the one thing the ecosystem has solved. 97% of the domains that were graded hold an A-grade TLS configuration. After that, adoption falls off a cliff.
Table view
| Measure | Share | Of |
|---|---|---|
| TLS grade A | 97% | 521 of 538 |
| Privacy policy page | 78% | 332 of 425 |
| SPF record | 62% | 364 of 584 |
| DMARC record | 55% | 320 of 584 |
| DKIM key on a common selector | 46% | 270 of 584 |
| Content Security Policy | 34% | 208 of 614 |
| SPF + DKIM + DMARC | 32% | 188 of 584 |
| Consent mechanism | 16% | 68 of 425 |
| DNSSEC | 9% | 53 of 584 |
| security.txt | 7% | 41 of 611 |
Email authentication is where the numbers are most uncomfortable. Of the 584 domains with a DNS result, 62% have an SPF record, 55% have DMARC, 46% have a DKIM key on a common selector, but only 32% have the complete set, and 27% have none of the three. A domain with none of them can be impersonated in email to its own customers with no technical obstacle.
Security headers tell the same story in miniature. The median domain is missing 4 of the headers we check; 62% are missing three or more, and only 20% are missing none. These are single-line configuration changes on every major platform.
The most common findings
Among the 77 fully scanned domains, the scanner produced 571 findings, 7.4 per site on average. The ten most common are all configuration: a disclosure file, a DNS flag, and a run of missing headers.
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 |
Two things stand out. First, security.txt, the standard file that tells a researcher where to report a vulnerability, is absent on 86% of sites. It costs nothing and it is the difference between a quiet fix and a public write-up. Second, DMARC set to p=none on 29 sites: the record exists, so the box looks ticked, but it enforces nothing.
Critical and high severity findings
56% of fully scanned domains (43 of 77) carry at least one high or critical finding. 9 carry a critical. Exactly one site had no findings at all.
Table view
| Grade | Domains | Share |
|---|---|---|
| A+ | 2 | 3% |
| A | 31 | 40% |
| B | 19 | 25% |
| C | 14 | 18% |
| D | 3 | 4% |
| F | 8 | 10% |
What the criticals are
Table view
| Measure | Count | Of |
|---|---|---|
| CSP with both 'unsafe-inline' and 'unsafe-eval' | 8 | 8 of 9 |
| Known CVEs in an outdated CMS | 1 | 1 of 9 |
8 of the 9 criticals are the same finding: a Content Security Policy whose script-src allows both 'unsafe-inline' and 'unsafe-eval'. That policy blocks nothing an attacker would try, and it is worse than no policy, because it reads as done. The remaining critical was a content management system running with known, published CVEs.
What the highs are
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 | 4 | 4 of 77 |
High severity is dominated by email authentication (a missing DMARC record on 14 domains, a missing SPF record on 8) and by weak Content Security Policies. Behind those come an application port answering on 8080 on 4 domains and a CORS configuration that reflects any origin on 2.
Technology and stack
Where the scanner could identify the stack, Next.js is the most common framework and Vercel the most common host. Almost half of the domains show no identifiable framework from the outside, which is itself a signal: they are static sites, custom stacks, or stacks that do not advertise themselves. Individual frameworks and hosts are shown as counts; none of them is compared with another.
Table view
| Measure | Share | Of |
|---|---|---|
| Next.js | 17% | 103 of 616 |
| WordPress | 7% | 43 of 616 |
| React | 6% | 34 of 616 |
| Express.js | 2% | 15 of 616 |
| Lovable | 2% | 14 of 616 |
| Angular | 2% | 12 of 616 |
| Vite | 2% | 11 of 616 |
| ASP.NET | 2% | 11 of 616 |
| jQuery | 2% | 10 of 616 |
| Astro | 1% | 9 of 616 |
| Shopify | 1% | 9 of 616 |
Table view
| Measure | Share | Of |
|---|---|---|
| Vercel | 24% | 145 of 616 |
| Cloudflare Pages | 16% | 99 of 616 |
| Render | 6% | 36 of 616 |
| Netlify | 5% | 28 of 616 |
| Railway | 1% | 7 of 616 |
| AWS | 1% | 6 of 616 |
| GitHub Pages | 1% | 6 of 616 |
37% of domains sit behind Cloudflare, 15% of the 530 whose backend could be checked use Supabase, and Firebase barely appears. The Supabase share matters for the next section: the sites built on AI app builders lean on it heavily.
AI app builders against everything else
The detector attributed 45 of the 616 domains to an AI app builder at gold or silver confidence: Lovable 39, Base44 5, v0 1. Another 23 were self-reported as AI-built but could not be confirmed and are kept separate. To compare like with like, this section uses only the public tier, where both groups ran the same scan profile: 40 builder-made domains against 488 with no builder detected, each measure over the domains its check answered for.
Table view
| Measure | AI app builder detected | No builder detected |
|---|---|---|
| HSTS present | 100% (40/40) | 63% (305/486) |
| TLS grade A | 100% (36/36) | 97% (405/419) |
| DMARC record | 81% (29/36) | 52% (240/460) |
| SPF record | 69% (25/36) | 60% (278/460) |
| DKIM key on a common selector | 31% (11/36) | 46% (210/460) |
| Content Security Policy | 15% (6/40) | 36% (175/486) |
| No missing headers | 8% (3/40) | 21% (101/486) |
| High or critical finding | 33% (13/40) | 53% (261/488) |
| Supabase backend | 54% (19/35) | 9% (39/413) |
The builder-made sites lead on the controls a platform can set once. HSTS is on 100% of them against 63% elsewhere. DMARC is at 81% against 52%. Every builder-made site that was graded holds an A on TLS, against 97%. A high or critical finding appears on 33% of them against 53%. The pattern is what platform-set defaults would produce; the dataset records the outcome, not the mechanism.
They trail on the controls that need a decision from the person shipping. A Content Security Policy exists on 15% of builder-made sites against 36%. Only 8% have a complete header set, against 21%: most builder-made sites are missing a similar number of headers, which is consistent with a fixed subset being set for all of them. A DKIM key was found on a common selector for 31% against 46%, which tracks with domains that were bought recently and have not sent mail yet.
And 54% of builder-made sites run on Supabase, against 9%. That is the single biggest structural difference in the dataset. It is not a finding in itself: in the full-coverage stratum, the scanner found no row-level-security problems on any of the 16 Supabase-backed sites it could probe. It does mean that the security of a large share of builder-made apps now rests on one vendor's defaults and one developer's understanding of row-level security.
The 21 self-reported, unconfirmed AI-built domains look different again: 5% with a Content Security Policy, 29% with DMARC, 71% with a high or critical finding. The group is too small for a claim, and it is shown only to make one point: “built with AI” and “built on an AI app builder” are not the same population.
Patterns and surprises
- The dangerous things are rare; the cheap things are everywhere. One real secret leak in 77 fully scanned sites, no database rule failures, one leaking API route. Meanwhile, across all the domains whose homepage answered, two thirds have no Content Security Policy and nine in ten have no security.txt.
- A policy that exists can be worse than none. 8 of 9 criticals are Content Security Policies that allow everything, and 29 of 77 DMARC records are set to enforce nothing. Both look done from the dashboard.
- Defaults look like the largest security force in this dataset. The builder-made sites show it in both directions: HSTS on all of them, a Content Security Policy on few of them. The report measures the outcome; the mechanism is the most plausible reading, not a measured fact.
- “Public key” is not “leaked key”. 12 of the 13 sites with a secrets finding carried Supabase or Firebase keys that belong in the browser. Reports that count them inflate the problem by an order of magnitude.
- Email authentication is the quiet failure. 27% of domains with a DNS result have none of SPF, DKIM or DMARC, and it is invisible until someone impersonates you.
Recommendations for developers
- Ship a Content Security Policy, and never with both 'unsafe-inline' and 'unsafe-eval'. Start in report-only mode, use nonces or hashes for inline scripts, and turn on reporting so you learn what breaks before your users do.
- Finish email authentication. SPF, DKIM and DMARC together, and move DMARC from p=none to quarantine once the reports are clean. A record set to none is a monitoring tool, not a defence.
- Set the full header set once. Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options or frame-ancestors, Referrer-Policy and Permissions-Policy are a single configuration block on every major host.
- Publish security.txt. One file at
/.well-known/security.txtwith a contact and an expiry. It is how you find out first. - If you build on an AI app builder, own what the platform does not. In this dataset the builder-made sites arrive with TLS, HSTS and DMARC and mostly without a Content Security Policy; the database rules are yours in every case.
- Know which keys are public. Supabase anon keys and Firebase web keys are meant to ship to the browser; the security lives in the rules behind them. Service keys, JWT secrets and API tokens never ship. If one did, rotate it at the provider before you touch the code.
- Close what you are not serving. An application port answering on 8080 or 8443 next to your production site is an extra front door.
- Treat consent as part of the stack. A tracker that loads before a consent choice is a compliance finding on 23 of 77 fully scanned sites, and it is the easiest one on this list to fix.
Limitations of the dataset
- Self-selection. These are sites that were submitted for a scan, most of them through the public checker under a self-declaration of entitlement. People who worry about security scan more; people who do not, do not. The population is not the internet.
- Two coverage levels. Adoption rates are measured across stratum A over the domains each check answered for; findings and exposures only across the 77 with complete coverage. The report never mixes them, and readers should not either.
- Partner skew in stratum B. 46 of the 77 full-coverage domains came through one partner programme with its own audience.
- The builder detector sees fingerprints, not intent. A site built with an AI assistant inside a conventional framework is not detected. “No builder detected” contains such sites, so the comparison is builder platforms against everything else, not AI against humans.
- External view only. No source code, no authenticated surface, no runtime behaviour beyond what an unauthenticated visitor can observe. A DKIM key on a custom selector is invisible from here.
- Four months, one scanner. Severity follows one deterministic rule set at the time of each scan; other scanners would grade some findings differently.
- Small groups are small. 40 builder-made domains support directional statements, not decimals; groups below thirty are shown as counts and never compared.
Conclusion
The state of indie web app security in 2026 is not a story about zero-days. It is a story about defaults. Where a control arrives set, it is present on nearly every site that got it. Where the control needs a decision from the person shipping, it is absent more often than not, and the absence is invisible until it is expensive.
The AI app builders have made that pattern legible. The sites built on them carry better transport and email security than the average site, and weaker content security. The lesson is the same for every platform and every developer: the controls in this report are cheap, documented and boring, and they are still the difference between most of these sites and a secure one.
UNPWNED will repeat this measurement. The next edition will use the same definitions, so the numbers can be compared rather than reinterpreted.
UNPWNED (2026). The State of Indie Web App Security 2026. https://www.unpwned.io/research/state-of-indie-web-security-2026 Data as of 2026-09-03. Licensed CC BY 4.0: reuse with attribution. Corrections: [email protected]
