About forty academic women — from Stanford, Oxford, Max Planck, Karolinska, Amsterdam UMC, Geneva — fill out a sign-up form on a professional repository in their field, and never hear back.
In spring 2026, I was engaged on a small contract for the Women in Neuroscience Repository. The website was functional, but the team had flagged some minor issues, mostly on the server side. Some things as updating the dependencies or simplifying the production pipeline.
After the initial onboarding, while working through the codebase, I noticed a strange pattern. One of the core features — being able to sign up and create an account on the repository — did not seem to work as expected. My hypothesis was that the SendGrid (now Twilio) API key used to send confirmation mails had expired, and that rotating it would restore the normal authentication flow.
It was not the case. The key was working. The mail was simply never being sent.
Something was off, and I started to investigate. What I found: about 42 stranded researchers from top universities, and 454 spam-bot accounts that had managed to slip through the system. For any small organization that wants to be a curated repository of real people, that is a critical situation.
The 454 accounts removed on 2026-04-11, plotted by signup week. Bot-only — legitimate signups not shown (the production user table was not exported as part of this engagement).
The engagement
WiNRepo — the Women in Neuroscience Repository — is a website that has been live since around 2020. It aims at making women neuroscientists more discoverable: a public, searchable directory with profiles, institutions, modalities, and publications. It is volunteer-run, open source at WomenInNeuroscience/winrepo, deployed on PythonAnywhere.
The initial scope, from the kickoff meeting, was something very usual for an aging site: a painful manual deploy, a partly broken admin, and several intermittent 500s. Budget was fixed at a few hundred francs as we scoped a few hours initially.
It was a tight challenge on the client’s side, because the team would not get further funding without a working website, and grant reviewers would expect a better one, a common chicken-and-egg problem with non-profits. Time was also a constraint: summer volunteers were expected to start contributing, and the dev setup had to be ready for them.
So naturally, we decided for a small session of dependencies update first, as it is a bonded mandate and with predictable timelines. I started working through that scope, but the signup-email problem smelled fishy, so I started investigating in parallel.
The detective work
First, starting to go through the normal user verification. Error 500 on signup. Strange. Looking at the server logs, it is failing on the function calling SendGrid. No stack trace, no detail. From Django’s perspective, the email had been “sent” successfully (handed to the backend, which then refused it).
The working theory was that the SendGrid API key had expired. Easy to test with a couple of curl requests:
curl -H "Authorization: Bearer $SENDGRID_API_KEY" https://api.sendgrid.com/v3/user/profile
# 200 OK — key is valid
curl -X POST -H "Authorization: Bearer $SENDGRID_API_KEY" \
https://api.sendgrid.com/v3/mail/send -d @test-email.json
# 401 Maximum credits exceeded
The curl response was the first real clue. Key still valid; account exhausted. But only a handful of researchers should have signed up in the last month — well below the 100-message-per-month free-tier cap. And reCAPTCHA v2 was active on the form. So how had we been burning through 100 emails on a small repository site?
I pulled the user table out of production. Almost a thousand users — 970. Of those, 423 were active, while 547 were unconfirmed signups stretching back to 2021. Something was fully off. More than half the user base had never confirmed an email, and we were getting close to a thousand records with nobody home.
That is a loss for the women-neuroscientists community on its own. But it was also the explanation for the email problem: those 547 ghosts had each triggered a confirmation email at signup, and a steady drip of them was enough to exhaust the free quota every month.
The ghosts had a signature. Always single CamelCase, plausible-looking but not real — Ralphkig, JefferyMop, HarveyJoige, KennethCralf. Real-name-plus-syllable. The shape that account-creation bots have been using for years. reCAPTCHA v2 was active, but it has been routinely defeated by paid solver services for half a decade. It slows casual scripts; it does nothing against a determined operator.
The shape of the attack was not background noise. In March–April 2024, two months registered more than 232 signups. August and October 2024: about 37 each. February 2025: 61 signups, most of them in a four-day burst. Any impossible traffic to get from the real community.
Looking at the way they registered, there was no human operator behind them. The bots seemed to be following spikes in traffic and visibility for the website, and none of these signups were ever validated, so no one was actively using these accounts for anything. Two things to do: harden the website, then clean up the mess, because the database was in a garbled state.
Cleanup needed to be conservative. The risk was deleting a real high-profile user whose handle happened to look bot-like. The criteria I settled on:
- Inactive for more than 30 days
- Never logged in
- No profile attached
- AND (email on a known disposable-domain list OR
username == nameexactly, matching the single-CamelCase shape)
I tightened the rule mid-pass after a borderline case (Vbharat / vinita) almost fell into the bot bucket. The final criterion required username == name exactly — a signature no real signup produces by accident. After backing up the full database (a 64 MB gzipped dump), I deleted 454 accounts. The user count went from 970 to 516; the 423 active users were untouched.
From the same query I produced two preservation lists:
- 42 likely-legitimate academic researchers whose confirmation emails had been swallowed by the SendGrid cap. These are people the team needs to re-invite once email is working again.
- 41 ambiguous accounts — personal Gmail addresses with plausible names, no other bot signature. Not something a script can adjudicate, but a list of 40 is manageable by a human.
The anti-spam work itself sits in a commit that added a honeypot field: a form field rendered off-screen, invisible to humans, that bots fill in by reflex. If the field is filled, the signup is rejected. It catches the majority of indiscriminate signup bots without adding any friction for real users. The branch also adds a disposable-email-domain blocklist and five new tests covering both. It is in review with the team.
The infrastructure layer
So we knew what was happening with the ghost accounts. But the more interesting question is: what made it possible? What was the infrastructure gap that left the team unable to see what was happening and fix it in time?
That is what most of the rest of the engagement was about. Full diff is in PR #48 and the bonus PR #49, the second PR was extra fixes I added because they came up while I was already in the codebase, on the same budget. The goal was to make the website less technically demanding to operate, so that anyone on the team — not only a developer — could keep things moving and address technical issues as they came up.
The first thing was the deploy pipeline. Deploys had been manual: SSH into PythonAnywhere, git pull, run migrations by hand, reload the webapp. Easy to forget a step, easy to do half-asleep, no record of what shipped when. I separated everything out. There is now a GitHub Actions workflow that runs the test suite on push to main — and there is a real test suite now, sitting at 83% coverage, which means we can actually ship without fearing we will break something. If CI passes, a deploy job SSHes into PythonAnywhere and runs the refresh script. No more manual deploys.
I also fix various issues. Without going into details, a few things that I saw there, not a cleaner migration schema, backups (the systems was generating empty files!), The Django admin needed a pass too.
On the client side, I also increased the public profile search nospeed. Before, every keystroke triggered a full page reload and a database query. Now there is a small DRF endpoint at /api/profiles/ (unpaginated, public profiles only, with select_related and annotate to avoid N+1) and a vanilla-JS module that fetches the full set on page load and filters in the browser as the user types, debounced at 150 ms. Server-side search remains as a no-JS fallback. No framework added.
The signup flow itself (#47) used to crash with a 500 when the email send failed — but the user record had already been saved, leaving the user in a half-registered state. Wrapped in try/except with logging and a user-facing message. The signup now completes gracefully and tells the user there was an email issue, with the contact address to resolve it. This fix means the SendGrid cap surfaces as a graceful error rather than a 500.
Python was bumped from 3.7 to 3.10 across CI, Docker, and production. The Dockerfile had been pinned to the floating tag python:3, which was drifting away from the production target.
What landed in production
- 17 → 61 tests, 83% coverage, gated in CI at
--fail-under=50 - All public pages returning 200, verified post-deploy
- Automated daily MySQL backups, 30-day retention, with the flags PythonAnywhere actually needs
- Migrations committed to the repo, no more regenerate-on-deploy
- Python 3.10 across CI, Docker, and production
- 454 spam-bot accounts removed; 970 → 516 users, 547 → ~90 inactive
- Push-to-deploy on
mainvia GitHub Actions, no more manual SSH - Signup no longer crashes when email is misconfigured
Pending
A few things landed in code but need a new phase of work:
- Email backend migration. SendGrid’s free tier is exhausted and is not coming back without a paid plan. Recommendation: migrate to Brevo (300 emails/day free). About 30 minutes once an API key is in hand: swap
django-sendgrid-v5fordjango-anymail[brevo], update two settings. - Re-invite the stranded academics. Once Brevo is wired up, a one-shot management command can resend confirmation links to the ~42 researchers SendGrid had silently dropped. The list is preserved.
- Review the 41 ambiguous accounts. Personal Gmail addresses, plausible names, no clear bot signature. A human eye, not a script.
It has been a pleasure working with Hugo on the Women in Neuroscience Repository. He has always been very responsive, easy to communicate with, and genuinely invested in making our website as good as it can be. He has not only delivered the planned improvements on time but has also gone the extra mile by identifying and fixing additional issues. Throughout the process, he has also been very transparent about priorities and proactive in proposing practical solutions to improve the site’s security and accessibility. I would happily recommend Hugo to anyone looking for a skilled, reliable, and collaborative web developer.
Ana Luísa Pinho
Closing
This was a fun engagement on a great project, with a team I was happy to work with. What started as a routine maintenance pass yielded real value: a website that went from looking nice to being functionally sound — including in places the team did not yet know to look.
If you have something similar — a few-years-old codebase that is quietly bleeding, an admin nobody fixed because nobody remembers when it broke, a CD pipeline that is actually a manual SSH session, a database with more ghosts than members — do not hesitate to get in touch. Happy to talk through whether a health check-up makes sense for your site.