Keeping fake and internal signups out of product analytics
· 7 min read · Disposable email
To keep fake and internal signups out of product analytics, classify each account once when it's created, store the result as a user property (for example signup_quality = real, suspect, fake or internal), and filter on that property in every report. Fake means a disposable domain or a machine-generated address. Internal means your own domain, your QA aliases and seeded test accounts. Tagging at signup is what makes this work: you can't reliably reconstruct who was fake from events after the fact.
The rest of this post covers the classification rules, how to send the tag, and how to apply it in PostHog, Mixpanel, Amplitude and SQL.
Why this matters more than it looks
Fake and internal accounts don't only inflate the signup count. They sit in the denominator of every rate you care about. A batch of scripted signups that never come back makes activation and week-one retention look worse than they are. A QA engineer who signs up twenty times a week and completes onboarding every time makes them look better. Either way, you end up tuning onboarding against noise.
If you're not sure where your fake signups come from in the first place, start with where fake signups come from and how to filter them. This post assumes some of them will get through your signup form, either because you chose to flag instead of block or because no filter is perfect, and deals with keeping them out of your numbers.
Four buckets, decided once
Use four values. Two is tempting ("real" vs "fake") and it hides the cases you most need to look at.
| Bucket | Rule | What to do in analytics |
|---|---|---|
internal |
Email on your company domain(s), a QA alias pattern, a seeded test account, or a reserved test domain | Exclude everywhere. Fine to drop at ingestion. |
fake |
Disposable domain (verdict.reason = disposable_domain) |
Exclude from product metrics. Keep a separate abuse dashboard. |
suspect |
Machine-generated local part (randomized_local_part), or another signal you trust less |
Exclude from headline metrics, review a sample monthly. |
real |
Everything else | Include. |
The suspect bucket exists because local-part heuristics are wrong sometimes. Apple Hide My Email and similar relay services produce random-looking addresses for real people, and pingutil can flag them as randomized; we explain the distinction in relay services are not disposable email. Some real non-English names also score as random. If you lump these into fake, you'll silently drop real customers from your retention charts.
Role addresses (info@, support@) get low_trust from pingutil, not is_spam. We'd leave those in real: a shared inbox signing up is unusual, but it's usually a real business.
Detecting internal accounts
Internal traffic is the easy part, and the part teams most often skip. The rules are yours, not an API's:
- Your own domains. Include old domains and acquired companies' domains.
- QA aliases. If your team uses plus addressing like
dev+onboarding-test@gmail.com, match the plus tag. See how plus addressing and Gmail dots work before you write the pattern. - Reserved test domains.
example.com,example.organdexample.netare reserved for documentation by RFC 2606, and end-to-end test suites love them. - Seeded accounts. Anything created by a script with a known prefix.
Classify at signup
Here's a classifier that combines your internal rules with pingutil's fast mode, which skips company enrichment and answers in milliseconds:
// signup-quality.ts (server side)
export type SignupQuality = 'internal' | 'fake' | 'suspect' | 'real';
const INTERNAL_DOMAINS = new Set(['yourco.com', 'yourco.io']);
const TEST_DOMAINS = new Set(['example.com', 'example.org', 'example.net']);
const QA_PLUS_TAG = /\+(test|qa|e2e)[^@]*@/i;
export async function classifySignup(email: string): Promise<{
quality: SignupQuality;
reason: string | null;
category: string | null;
}> {
const lower = email.trim().toLowerCase();
const domain = lower.split('@')[1] ?? '';
if (INTERNAL_DOMAINS.has(domain)) return { quality: 'internal', reason: 'own_domain', category: null };
if (TEST_DOMAINS.has(domain)) return { quality: 'internal', reason: 'test_domain', category: null };
if (QA_PLUS_TAG.test(lower)) return { quality: 'internal', reason: 'qa_alias', category: null };
try {
const res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: lower, skip_enrichment: true }),
signal: AbortSignal.timeout(2000),
});
if (!res.ok) return { quality: 'real', reason: 'lookup_failed', category: null };
const data = await res.json();
if (data.verdict.reason === 'disposable_domain') {
return { quality: 'fake', reason: data.verdict.reason, category: data.category };
}
if (data.verdict.is_spam) {
return { quality: 'suspect', reason: data.verdict.reason, category: data.category };
}
return { quality: 'real', reason: data.verdict.reason, category: data.category };
} catch {
return { quality: 'real', reason: 'lookup_failed', category: null };
}
}
A few choices to notice. Internal checks run first, so your own team never costs a lookup. A failed lookup defaults to real with a reason you can find later, rather than blocking signup or dropping the user from metrics. And category is stored too, since "real signups by email type" is a useful chart on its own.
Store the tag in two places
Write signup_quality, signup_quality_reason and email_category to your users table, then send them to your analytics tool as user (person) properties. With PostHog's Node library that's one identify call:
import { PostHog } from 'posthog-node';
const posthog = new PostHog(process.env.POSTHOG_KEY!, { host: 'https://us.i.posthog.com' });
export async function tagUser(userId: string, email: string) {
const q = await classifySignup(email);
await db.users.update(userId, {
signup_quality: q.quality,
signup_quality_reason: q.reason,
email_category: q.category,
});
posthog.identify({
distinctId: userId,
properties: {
signup_quality: q.quality,
signup_quality_reason: q.reason,
email_category: q.category,
},
});
}
Mixpanel (people.set) and Amplitude (an Identify call) have equivalents. Send the property from the server, not the browser, so a user can't change their own bucket. Also re-send it when your view of an account changes, for example when support bans a user that slipped through as real.
Classify once and keep the result. The disposable list behind pingutil is re-synced weekly, so a domain added next month won't retroactively change this user's tag unless you re-run the check on purpose. That's usually what you want: a stable label that matches what your signup form saw.
Filtering in each tool
PostHog. Project settings have a "Filter out internal and test users" section where each entry is a property filter. Add signup_quality is not real, or keep internal and fake separate if you want to see suspect users sometimes. Per PostHog's internal and test users docs, person property filters are the option that works everywhere, and the filter can be toggled per insight or turned on by default for new insights. It hides data from insights; it doesn't stop capture. If you want to drop internal events entirely, PostHog has separate ingestion-time event filtering.
Mixpanel. Data Views let you restrict a view of the project using event, event property and user profile property filters. A "Customers" view with signup_quality equals real gives every report the clean dataset without anyone remembering a filter. Mixpanel's data views documentation lists the feature on the Growth plan and above. On a plan without it, add the user property filter to each report or save it in a shared board.
Amplitude. Use the Segmentation module on a chart to exclude users by a user property, and save that segment so others can reuse it. Amplitude's instrumentation troubleshooting guide also describes blocking internal traffic by IP at ingestion. IP blocking is fine for an office, less so for a remote team, so the user property is the more dependable filter.
The same filter in SQL
If your events land in a warehouse, join to the users table and filter there. This weekly signup chart keeps every bucket visible, which is how you notice a spike in fakes:
-- PostgreSQL
select
date_trunc('week', created_at) as week,
count(*) filter (where signup_quality = 'real') as real_signups,
count(*) filter (where signup_quality = 'suspect') as suspect,
count(*) filter (where signup_quality = 'fake') as fake,
count(*) filter (where signup_quality = 'internal') as internal
from users
group by 1
order by 1;
And activation computed only on real users:
select
date_trunc('week', u.created_at) as week,
avg(case when a.user_id is not null then 1.0 else 0 end) as activation_rate
from users u
left join (
select distinct user_id from events where name = 'project_created'
) a on a.user_id = u.id
where u.signup_quality = 'real'
group by 1
order by 1;
Swap project_created for your own activation event.
Checklist
- Add your own domains, QA alias pattern and reserved test domains to the internal rules.
- Classify every new account once, server side, with a short timeout and a
realfallback. - Store the tag in your database and as a user property in your analytics tool.
- Turn on the tool's default filter (PostHog), a clean data view (Mixpanel) or a saved segment (Amplitude).
- Keep one dashboard that shows all four buckets, and sample the
suspectbucket by hand monthly.
The disposable domains behind the fake bucket are listed on the disposable category page. If the suspect bucket is large, detecting machine-generated email addresses explains how the randomness check decides.
Questions people ask
- Should I drop fake signups at ingestion or filter them at query time?
- Filter at query time. You want the fake accounts visible somewhere so you can measure abuse and check that your filters work. Dropping at ingestion makes sense for your own internal traffic, which you never need to analyze.
- How do I exclude my own team from analytics?
- Mark users as internal when their email is on your own domain, matches your QA alias pattern, or belongs to a seeded test account. Store that as a user property and use your analytics tool's internal-user filter or a saved segment on it.
- Will filtering disposable emails remove real users from my metrics?
- A few, possibly. Privacy relay addresses and some real names can look machine-generated, so keep a separate 'suspect' bucket instead of lumping everything flagged into 'fake', and check it by hand now and then.