Security

Signup Is a Production Surface

July 2, 2026

The production configuration fix made environment ownership explicit. Signup exposed why that wiring mattered.

A small form with name, email, password, and confirmation can burn email quota, send unwanted mail to third parties, reveal whether an account exists, fill the database with disposable identities, and make real users harder to see in the logs.

The uncomfortable part is that none of this requires a sophisticated attacker. A simple script and a list of disposable domains are enough to turn a polite onboarding flow into a small abuse machine.

Email is not a harmless side effect

The first mistake is treating confirmation email as an implementation detail.

It is not.

Every confirmation email is an external action. It costs money, reputation, deliverability, and attention. If your system can be tricked into sending thousands of messages to addresses that never wanted them, the signup endpoint accepts bad input and participates in abuse.

That changes the design.

Valid email syntax is only the first check. The endpoint also has to decide whether this request should be allowed to send an email from the system.

That question needs more than a regex.

Format validation catches mistakes. Disposable-domain checks catch obvious throwaway identities. DNS and MX checks can raise the bar further, as long as they are bounded by timeouts and caching. Rate limits prevent a single IP or email target from turning the endpoint into a sending loop.

None of these defenses is perfect.

Together, they make abuse less cheap.

Neutral responses protect users

There is another easy trap in auth flows: helpful error messages.

Helpful to whom?

If a resend-confirmation endpoint says "No account exists for this email", it helps a legitimate user correct a typo. It also helps an attacker enumerate accounts.

If a login or signup endpoint reveals too much about verification state, it can become a directory of who has registered.

For sensitive endpoints, the public response should often be boring:

If an account exists and the request is allowed, we will send instructions.

That may feel less satisfying than precise feedback, but precision belongs in private logs, not public probes. The user experience can still be decent. The product can show calm, generic copy while the system records the real reason internally.

Security hardening is often described as adding friction. I think the better framing is deciding where the friction should live.

A real user should not have to solve your security model.

An attacker should not get free information because the product wanted to be conversational.

Different actions deserve different limits

Rate limiting is not one number.

Signup is different from login.

Login is different from email confirmation.

Email confirmation is different from resend.

Tenant resolution, password reset, and guest access all have different risk profiles.

A token-protected email confirmation link may reasonably have a looser per-IP limit than a signup endpoint that sends fresh email. A signup flow may need both per-IP and per-email limits. A resend flow should probably care about the target address rather than only the client address.

This sounds obvious once written down, but many systems start with a single anonymous throttle and hope it is enough.

It usually is not.

The more useful model is to name the behavior you are protecting.

Protect send quota.

Protect account existence.

Protect token verification.

Protect shared infrastructure.

Then give each behavior a limit that matches the risk.

The cache is part of the security system

Rate limits are only as real as the storage behind them.

In development, an in-memory cache may be fine. In production, if multiple processes are handling requests, throttling needs shared state. That usually means Redis or another shared cache.

This is one of those places where security and deployment meet. The code can define all the right throttle scopes, but if production points them at the wrong cache, or at a cache that is not shared, the protection is weaker than it looks.

The deployment checklist is part of the feature.

So are the dependencies.

So are the smoke tests that prove repeated requests actually produce a 429.

Building with AI: I defined the abuse cases and public-response rules; AI wrote much of the validation, throttling, and test scaffolding. The first DNS fallback treated a transient resolver failure as a durable rejection and cached it. A threat-model review and a focused failing test exposed the mistake, after which only durable NXDOMAIN or missing-record results could be cached as rejects.

What changed in the real flow

The Vowframes audit found three concrete holes: auth views had no attached throttle classes, signup accepted disposable domains such as Mailinator, and User.email_verified defaulted to True for paths that had never proved ownership of the address.

The fix had five parts:

  • apps/common/email_validation.py normalizes the address, checks a disposable-domain set, and performs bounded DNS delivery checks;
  • registration and owner-signup validate before creating a user or queuing mail;
  • resend keeps a neutral public response, including for a blocked address;
  • signup_email limits by normalized target email, while auth, email_confirm, and login have separate per-IP scopes;
  • email_verified defaults to False; only a confirmed link or a trusted OAuth path sets it to true.

The DNS behavior is deliberately asymmetric. NXDOMAIN, or no MX and no A record, is a durable rejection. Resolver timeouts and transient nameserver failures fail open and are not cached as rejects. A review caught the A-record fallback accidentally converting a transient failure into a cached denial; a dedicated red/green test now covers it.

The behavior is controlled by DJANGO_CACHE_URL, BLOCKED_EMAIL_DOMAINS, EMAIL_CHECK_DELIVERABILITY, and separate throttle rates for auth, signup email, confirmation, and login. Their values are operational policy. Redis uses a separate logical database from the Celery broker; it does not require another Redis server.

I changed the client IP while keeping the target email constant, forced transient DNS failures, checked neutral resend responses, and repeated requests until the endpoint returned 429. The same protection was then carried from Vowframes into Sivella.

A production smoke test still matters because local tests cannot prove proxy hops or shared cache wiring. I use a controlled address and environment, repeat requests from one external client until the endpoint returns 429, and avoid sending confirmation mail to an uninvolved third party.

Small products are still on the internet

There is a stage of building where security work feels premature. The product is new. Traffic is low. The team is small. There are bigger features to ship.

But the internet does not care about your stage.

If your signup form can send email, it can be abused.

If your auth flow reveals account state, it can be probed.

If your rate limits depend on a cache that is not wired correctly, they may not exist in the way you think they do.

The work does not require a fortress before the first user. It requires treating every outbound email, cache-backed limit, and public response as production behavior from the day the endpoint becomes reachable.

Disposable signups, per-address email bombing, broad credential stuffing, and verify-by-default accounts now meet separate controls before they consume mail and support capacity. Named throttle scopes, shared-cache configuration, and failure-path checks keep that protection out of a blocklist somebody has to remember by hand.