176M Americans with PFAS detected in drinking water
12,000+ PFAS compounds in use — most never tested
97% Of Americans have measurable PFAS in their blood
43M On private wells with little federal oversight

See PFAS in Your Area

Enter your zip code to see public water systems, PFAS detection data, and the elected officials responsible for fixing it.

EPA ECHO & UCMR 5 · No data stored · Government sources only

Water Systems
Enter a zip code above to see PFAS detection data for water systems in your area.

📣 Contact Your Representatives

📣

Your elected officials appear here after you search your zip code

📊 Open Active PFAS Data Dashboard →

What Are PFAS?

PFAS are a family of over 12,000 synthetic chemicals used since the 1940s in nonstick cookware, water-resistant clothing, food packaging, firefighting foam, and hundreds of industrial processes. Their carbon-fluorine bonds are among the strongest in chemistry — which is why they persist indefinitely in the environment and in the human body.

Contamination spreads through soil and groundwater from industrial discharge sites, military bases that used PFAS-containing firefighting foam, and landfills accepting PFAS-laden waste. Studies consistently detect PFAS in the blood of nearly all Americans, including newborns.

Water utilities did not create this problem. In most cases, local water systems are downstream victims of contamination that originated at manufacturing plants, military installations, and industrial facilities that released PFAS over many years — often before the risks were publicly known or regulated. The utilities now bear the cost of detecting and removing chemicals they did not put there.

Health effects linked to PFAS exposure include thyroid disruption, immune suppression, certain cancers, and developmental impacts in children. The EPA finalized maximum contaminant levels (MCLs) for six PFAS in drinking water in April 2024 — though the regulatory landscape continues to evolve.

What the numbers mean PFAS are measured in parts per trillion (ppt). The EPA's 2024 standard for PFOA is 4 ppt — one of the most stringent drinking water limits ever set. Even at these vanishingly small levels, health effects have been documented.
⚠️ "Not tested" ≠ "Clean" Many areas — especially small water systems and private wells — have never been sampled. We clearly distinguish untested areas from tested-and-clear results.
Who introduced PFAS? Industrial manufacturers, military operations, and chemical companies discharged PFAS into the environment over decades. Those responsible are increasingly subjects of public litigation. Water utilities are not the source.

Support the Mission

No ads. No water filters for sale. No industry funding. PFASData.com exists because people think the public deserves clear, honest data. Here's how it stays alive.

Now

Reader Donations

A direct contribution keeps the site independent and pays for writers, data engineers, and hosting.

Now

Newsletter Sponsorship

One clearly-labeled sponsor spot — nonprofits, law firms, and public health orgs only. Never filter companies.

Now

Grants & Foundations

We pursue journalism and environmental health grants. If you work at an aligned foundation, let's talk.

Now

Data Licensing

Law firms, researchers, and public health agencies can license our cleaned, normalized PFAS dataset.

Coming

Pro Reports

Detailed PDF reports on specific water systems or regions — for legal cases, real estate, or community organizing.

Coming

API Access

Structured API access for developers and researchers. Personal and nonprofit use stays free.

Support PFASData.com →

We always disclose how this site is funded. Donor and sponsor lists will be published.

Get PFAS Alerts

New EPA data drops. Regulatory changes. Major findings. Concise updates — no spam, no filters for sale.

* indicates required
*/ (function () { 'use strict'; // ── Mobile menu toggle ─────────────────────────────────────────────────── function initMobileMenu() { const btn = document.querySelector('.site-nav__hamburger'); const menu = document.querySelector('.site-nav__mobile'); if (!btn || !menu) return; btn.addEventListener('click', () => { const isOpen = menu.classList.toggle('open'); btn.setAttribute('aria-expanded', isOpen); document.body.style.overflow = isOpen ? 'hidden' : ''; }); // Close on outside click document.addEventListener('click', (e) => { if (!btn.contains(e.target) && !menu.contains(e.target)) { menu.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); document.body.style.overflow = ''; } }); // Close on nav link click menu.querySelectorAll('a').forEach(link => { link.addEventListener('click', () => { menu.classList.remove('open'); document.body.style.overflow = ''; }); }); } // ── Active link highlight ───────────────────────────────────────────────── function highlightActiveLink() { const path = window.location.pathname.split('/').pop() || 'index.html'; document.querySelectorAll('.site-nav__links a, .site-nav__mobile a').forEach(link => { const href = link.getAttribute('href') || ''; const linkPage = href.split('/').pop().split('#')[0] || 'index.html'; if (linkPage === path && !href.startsWith('#')) { link.classList.add('active'); } }); } // ── Nav shadow on scroll ────────────────────────────────────────────────── function initNavScroll() { const nav = document.querySelector('.site-nav'); if (!nav) return; const observer = new IntersectionObserver( ([entry]) => nav.classList.toggle('site-nav--scrolled', !entry.isIntersecting), { rootMargin: '-1px 0px 0px 0px', threshold: 0 } ); const sentinel = document.createElement('div'); sentinel.style.cssText = 'position:absolute;top:0;height:1px;width:100%;pointer-events:none'; document.body.prepend(sentinel); observer.observe(sentinel); } // ── Init ────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initMobileMenu(); highlightActiveLink(); initNavScroll(); }); })(); /* officials.js */ /** * PFASData.com — officials.js * Google Civic Information API lookup. * * Root cause of previous failure: * Browsers do NOT send a Referer header on cross-origin fetch() calls * by default, so Google's HTTP referrer restriction on the API key * rejects the request even though the key is "restricted" to pfasdata.com. * * Fix options (in order of reliability): * 1. BEST: Proxy through Cloudflare Worker at /api/civic/ * → request originates server-side, no Referer issue * 2. FALLBACK: referrerPolicy: 'unsafe-url' — sends Referer on cross-origin * → works in most browsers but not guaranteed * 3. LAST RESORT: Remove HTTP referrer restriction from the key in Google Console * and restrict by IP/hostname instead * * This file implements option 2 immediately, and falls back to * curated links if the API still refuses. * * Loaded via */ (function () { 'use strict'; // fetchOfficials is called from map.js — expose on window window.fetchOfficials = fetchOfficials; async function fetchOfficials(zip) { const key = window.CIVIC_API_KEY; if (!key) { renderFallback(zip, 'No API key configured.'); return; } const levels = 'levels=country&levels=administrativeArea1&levels=locality'; const roles = 'roles=legislatorUpperBody&roles=legislatorLowerBody&roles=headOfGovernment&roles=deputyHeadOfGovernment'; const url = `https://civicinfo.googleapis.com/civicinfo/v2/representatives` + `?address=${encodeURIComponent(zip)}&${levels}&${roles}&key=${key}`; try { // FIX: use 'unsafe-url' referrer policy so the browser sends // the Referer header Google checks against key restrictions const resp = await fetch(url, { referrerPolicy: 'unsafe-url', headers: { 'Accept': 'application/json' }, }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); const msg = err?.error?.message || `HTTP ${resp.status}`; console.warn('[officials] Civic API error:', msg); // If it's a key restriction error, show specific guidance if (resp.status === 403) { renderFallback(zip, 'API key restriction. In Google Console, ensure the HTTP referrer restriction allows: https://pfasdata.com/*'); } else { renderFallback(zip, msg); } return; } const data = await resp.json(); if (!data.offices?.length || !data.officials?.length) { renderFallback(zip, 'No officials found for this zip code.'); return; } renderOfficials(data, zip); } catch (e) { console.warn('[officials] Fetch failed:', e.message); renderFallback(zip, e.message); } } // ── Render officials list ───────────────────────────────────────────────── function renderOfficials(data, zip) { const body = document.getElementById('officials-body'); if (!body) return; const offices = data.offices || []; const officials = data.officials || []; // Build flat list with level metadata const items = []; offices.forEach(office => { (office.officialIndices || []).forEach(idx => { const off = officials[idx]; if (!off) return; items.push({ office: office.name, level: (office.levels || ['locality'])[0], name: off.name, party: off.party || '', urls: off.urls || [], phones: off.phones || [], emails: off.emails || [], }); }); }); // Sort: federal → state → local const order = { country: 0, administrativeArea1: 1, administrativeArea2: 2, locality: 3 }; items.sort((a, b) => (order[a.level] ?? 4) - (order[b.level] ?? 4)); body.innerHTML = items.map(item => { const lvlLabel = levelLabel(item.level); const lvlClass = levelClass(item.level); const links = []; if (item.phones[0]) links.push(`Call ↗`); if (item.emails[0]) links.push(`Email ↗`); if (item.urls[0]) links.push(`Web ↗`); return `
${lvlLabel}
${escHtml(item.name)} ${escHtml(item.office)}${item.party ? ' · ' + escHtml(item.party) : ''}
`; }).join(''); } // ── Fallback links ──────────────────────────────────────────────────────── function renderFallback(zip, reason) { const body = document.getElementById('officials-body'); if (!body) return; // Log reason for debugging, don't show technical details to user if (reason) console.info('[officials] Using fallback. Reason:', reason); body.innerHTML = `

Use these official directories for ZIP ${escHtml(zip)}:

🏛️ Find your U.S. Senators & Representative 🇺🇸 USA.gov officials directory 🏠 Find your state legislators ⚖️ Your state Attorney General 🌿 Your EPA Regional Office
`; } // ── Helpers ─────────────────────────────────────────────────────────────── function levelLabel(level) { return { country: 'Federal', administrativeArea1: 'State', administrativeArea2: 'County', locality: 'Local' }[level] || 'Local'; } function levelClass(level) { return { country: 'ol-federal', administrativeArea1: 'ol-state', administrativeArea2: 'ol-county', locality: 'ol-local' }[level] || 'ol-local'; } function escHtml(str) { return String(str) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } })(); /* map.js */ /** * PFASData.com — map.js * Mapbox GL JS map + EPA ECHO proxy calls. * * Bug fixes vs. previous version: * 1. Map container is made visible BEFORE mapboxgl.Map() constructor runs * so GL JS can measure container dimensions correctly. * 2. Geocoding error propagates cleanly; map still shows on geocode failure. * 3. Marker stacking fixed with proper lat/lng jitter based on index. * 4. Proxy call uses relative URL — works on pfasdata.com and pages.dev previews. * 5. All async errors caught individually; one failure doesn't block others. * * Requires: MAPBOX_TOKEN defined in page */ (function () { 'use strict'; // ── State ───────────────────────────────────────────────────────────────── let _map = null; let _markers = []; // ── DOM refs (set on init) ──────────────────────────────────────────────── let _els = {}; // ── Init ────────────────────────────────────────────────────────────────── function init() { _els = { mapContainer: document.getElementById('pfas-map'), mapZip: document.getElementById('map-zip'), zipTag: document.getElementById('map-zip-tag'), mapEmpty: document.getElementById('map-empty'), resultsBody: document.getElementById('map-results-body'), resultsFoot: document.getElementById('map-results-footer'), footNote: document.getElementById('map-footer-note'), officialPanel:document.getElementById('officials-panel'), officialBody: document.getElementById('officials-body'), officialZip: document.getElementById('officials-zip-label'), officialPH: document.getElementById('officials-placeholder'), }; if (!_els.mapZip) return; // Not on a page with the map _els.mapZip.addEventListener('keydown', e => { if (e.key === 'Enter') runSearch(); }); // Expose globally so inline onclick can call it window.runMapSearch = runSearch; } // ── Main search ─────────────────────────────────────────────────────────── async function runSearch() { const zip = (_els.mapZip?.value || '').trim(); if (!zip || zip.length !== 5 || !/^\d{5}$/.test(zip)) { alert('Please enter a valid 5-digit US zip code.'); return; } setSearchingState(zip); clearMarkers(); // Run geocoding, water systems, and officials in parallel // Geocoding must resolve before map + markers, but officials is independent const [geo] = await Promise.all([ geocodeZip(zip).catch(e => { console.warn('[map] Geocode failed:', e.message); return null; }), fetchOfficials(zip), ]); if (geo) { showMap(geo.lng, geo.lat); } await fetchWaterSystems(zip, geo); } // ── UI state helpers ────────────────────────────────────────────────────── function setSearchingState(zip) { if (_els.zipTag) { _els.zipTag.textContent = zip; _els.zipTag.style.display = 'inline'; } if (_els.mapEmpty) _els.mapEmpty.style.display = 'none'; if (_els.resultsBody) _els.resultsBody.innerHTML = `

Querying EPA database…

`; if (_els.resultsFoot) _els.resultsFoot.style.display = 'none'; if (_els.officialPH) _els.officialPH.style.display = 'none'; if (_els.officialPanel) _els.officialPanel.classList.add('visible'); if (_els.officialZip) _els.officialZip.textContent = 'ZIP ' + zip; if (_els.officialBody) _els.officialBody.innerHTML = `
Finding your officials…
`; } // ── Mapbox ──────────────────────────────────────────────────────────────── function showMap(lng, lat) { const el = _els.mapContainer; if (!el) return; // FIX: show the container BEFORE creating the map so GL JS can measure it el.style.display = 'block'; if (_map) { _map.flyTo({ center: [lng, lat], zoom: 10, duration: 1000 }); return; } if (typeof mapboxgl === 'undefined') { console.warn('[map] mapboxgl not loaded'); return; } mapboxgl.accessToken = window.MAPBOX_TOKEN; _map = new mapboxgl.Map({ container: el, style: 'mapbox://styles/mapbox/dark-v11', center: [lng, lat], zoom: 10, attributionControl: false, }); _map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right'); _map.addControl(new mapboxgl.AttributionControl({ compact: true })); } function clearMarkers() { _markers.forEach(m => m.remove()); _markers = []; } function addMarker(lng, lat, name, detail, echoUrl, hasViolation) { if (!_map) return; const el = document.createElement('div'); el.style.cssText = [ 'width:13px', 'height:13px', 'border-radius:50%', 'border:2px solid #fff', `background:${hasViolation ? '#FFB74D' : '#5DCAA5'}`, 'cursor:pointer', 'box-shadow:0 2px 8px rgba(0,0,0,0.4)', ].join(';'); const popup = new mapboxgl.Popup({ offset: 14, closeButton: false, maxWidth: '220px' }) .setHTML(` ${escHtml(name)} ${escHtml(detail)} ${echoUrl ? `View EPA record ↗` : ''} `); const marker = new mapboxgl.Marker(el) .setLngLat([lng, lat]) .setPopup(popup) .addTo(_map); _markers.push(marker); } // ── Geocoding ───────────────────────────────────────────────────────────── async function geocodeZip(zip) { const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(zip)}.json` + `?country=US&types=postcode&access_token=${window.MAPBOX_TOKEN}`; const resp = await fetch(url); if (!resp.ok) throw new Error(`Geocoding HTTP ${resp.status}`); const json = await resp.json(); const f = json.features?.[0]; if (!f) throw new Error('No geocoding result for ' + zip); return { lng: f.center[0], lat: f.center[1], placeName: f.place_name }; } // ── EPA ECHO (via Cloudflare Worker proxy) ──────────────────────────────── async function fetchWaterSystems(zip, geo) { try { const params = new URLSearchParams({ output: 'JSON', p_act: 'Y', p_ptype: 'CWS', p_zip: zip, responseset: '10', qcolumns: '1,2,3,4,5,6,7,8', }); const resp = await fetch(`/api/echo/sdw_rest_services.get_facilities?${params}`); if (!resp.ok) throw new Error(`Proxy HTTP ${resp.status}`); const json = await resp.json(); if (json.error) throw new Error(json.error); const systems = json?.Results?.Facilities || []; renderResults(systems, zip, geo); } catch (e) { console.warn('[map] Water systems fetch failed:', e.message); if (geo && _map) addMarker(geo.lng, geo.lat, 'ZIP ' + zip, geo.placeName || '', '', false); renderFallback(zip); } } function renderResults(systems, zip, geo) { if (!systems.length) { renderFallback(zip); return; } let html = ''; systems.slice(0, 8).forEach((s, i) => { const name = s.FacilityName || 'Unknown System'; const city = s.LocationCity || ''; const pop = s.PopulationServed ? Number(s.PopulationServed).toLocaleString() + ' served' : ''; const type = s.SourceWaterType || ''; const hasViol = parseInt(s.SDWAViolations || 0) > 0; const echoUrl = `https://echo.epa.gov/facilities/facility-search/details?fid=${s.RegistryID || ''}`; const detail = [city, type, pop].filter(Boolean).join(' · '); // FIX: spread markers so they don't stack exactly — small spiral offset if (geo && _map) { const angle = (i / 8) * 2 * Math.PI; const r = i === 0 ? 0 : 0.004; addMarker(geo.lng + r * Math.cos(angle), geo.lat + r * Math.sin(angle), name, detail, echoUrl, hasViol); } html += `
${escHtml(name)} ${escHtml(detail)}
${hasViol ? 'Violations ↗' : 'Active ↗'}
`; }); _els.resultsBody.innerHTML = html; _els.resultsFoot.style.display = 'flex'; _els.footNote.textContent = `${systems.length} system${systems.length !== 1 ? 's' : ''} · ZIP ${zip}`; } function renderFallback(zip) { _els.resultsBody.innerHTML = `

The Worker proxy isn't active yet, or returned no results for this ZIP. Direct government sources:

`; _els.resultsFoot.style.display = 'flex'; _els.footNote.textContent = `Direct EPA links · ZIP ${zip}`; } // ── Utility ─────────────────────────────────────────────────────────────── function escHtml(str) { return String(str) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ── Start ───────────────────────────────────────────────────────────────── // Expose globally IMMEDIATELY (before DOMContentLoaded) so heroSearch() // can call it even on fast interactions before defer scripts fully init. window.runMapSearch = runSearch; document.addEventListener('DOMContentLoaded', init); })();