Short answer: use CAPTCHA as one input, then combine device and event signals before granting a Google or GitHub sign-in session. A single challenge is easy to automate and annoying for real students; a short-lived risk decision lets low-risk registrations pass while suspicious bursts get step-up checks.
Here is the decision matrix I use for a one-person edtech SaaS. It keeps the revenue-per-hour lens visible: every control must remove abuse without creating a support queue.
| Layer | Good at | Failure mode | Use it when |
|---|---|---|---|
| CAPTCHA (Turnstile, hCaptcha, or reCAPTCHA) | Detecting scripted interaction and browser signals | Solvers, accessibility friction, false positives | A request looks automated |
| Device signal | Linking velocity and reputation across sessions | Shared labs, mobile rotation, privacy limits | Many accounts target one event |
| Event signal | Catching impossible timing, quotas, and invite patterns | Bad thresholds can block a real class | Abuse clusters around a specific event |
| Step-up decision | Asking for email verification or manual review only when needed | Adds latency to edge cases | Signals disagree or risk is high |
My default is to collect all three signals, assign a reason-coded decision, and make the challenge threshold configurable per event. Ship weekly. Outsource the undifferentiated challenge rendering to a provider, but keep the policy and audit trail in your application.
Small rules win.
What should a Node.js event registration flow check before social sign-in?
Start before OAuth. A bot can consume seats or send invitation mail long before it proves ownership of a Google or GitHub account. Create an opaque registration attempt, bind it to the event, and record a server-side timestamp. Do not trust a score posted by the browser.
The event signal is usually the highest-signal input. Compare requests with the event's capacity, opening time, invite policy, and recent velocity. Ten attempts from one network in two seconds is different from ten attempts spread across a school day. Your mileage may vary: a public hackathon and a paid tutoring session need different baselines.
Device data should be coarse and privacy-aware. A salted, rotating device token can connect a browser's recent attempts without storing a raw fingerprint. Keep the retention window short. Treat IP and user-agent as supporting evidence, not identity; school networks and carrier NAT make that distinction important.
CAPTCHA should be a step-up, not the front door for everyone. Verify its token on your server, check the expected hostname or action, and reject expired or replayed tokens. Providers differ: Turnstile emphasizes a low-friction challenge experience, hCaptcha exposes a challenge-based flow, and reCAPTCHA offers score and checkbox modes. None of those differences replaces your event policy.
A small risk gate that is testable and explainable
The following TypeScript keeps the policy independent from any CAPTCHA vendor. It returns reasons so I can inspect a false positive without guessing.
type Decision = 'allow' | 'step_up' | 'deny';
type Signals = {
captchaPassed: boolean;
deviceAttempts10m: number;
ipAttempts1m: number;
secondsSinceEventOpen: number;
inviteRequired: boolean;
inviteValid: boolean;
};
export function decide(signals: Signals): { decision: Decision; reasons: string[] } {
const reasons: string[] = [];
if (!signals.captchaPassed) reasons.push('captcha_missing');
if (signals.deviceAttempts10m > 4) reasons.push('device_velocity');
if (signals.ipAttempts1m > 12) reasons.push('ip_burst');
if (signals.secondsSinceEventOpen < 3) reasons.push('event_timing');
if (signals.inviteRequired && !signals.inviteValid) reasons.push('invite_invalid');
if (reasons.includes('invite_invalid') || signals.ipAttempts1m > 30) {
return { decision: 'deny', reasons };
}
if (reasons.length > 0) return { decision: 'step_up', reasons };
return { decision: 'allow', reasons };
}
The numbers are starting points, not universal truth. Put them behind configuration, replay production-like traffic in tests, and review the reason distribution after each event. I once treated a burst as proof of a bad device; a school lab proved otherwise. The fix was to combine signals and add an allow-list for verified class networks, not to delete the control. The investigation took an afternoon because each decision carried a reason code, policy version, event id, and hashed device token. We could replay the exact input in a local test, compare it with the provider callback, and see that the apparent attack was twenty students sharing one egress address. That level of traceability matters more than a clever score: without it, a founder spends the next week answering “why was I blocked?” instead of shipping the next lesson.
After the gate returns allow, begin OAuth with a server-generated state and PKCE verifier. Store both with the registration attempt, then require an exact state match on callback. Google and GitHub each return an authorization code; exchange it server-side, map the provider subject to your local account, and only then reserve a seat. OAuth authenticates an account. It does not certify that the account is a human who should consume this event's quota.
import { randomBytes, createHash } from 'node:crypto';
export function createPkceAttempt() {
const state = randomBytes(32).toString('base64url');
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { state, verifier, challenge, method: 'S256' as const };
}
Keep the verifier in an encrypted, short-lived server session. The callback should be idempotent: a retried code must not create a second registration or send a second welcome message.
How do CAPTCHA, device signals, and event signals fail together?
Attackers adapt when a rule is obvious. They rotate IPs to defeat velocity, solve challenges in farms, and distribute signups across fresh browsers. That is why a deny decision should require a high-confidence combination, while a single weak signal usually triggers step-up. Rate-limit the token-verification endpoint itself, and emit structured events such as registration.step_up with a policy version.
Measure abuse and user cost together: challenged registrations, completion after challenge, duplicate accounts per event, seat-release rate, and support contacts. A falling bot count paired with a rising student failure rate is not a win.
There are clear boundaries. This design is not suitable when you need anonymous, offline admission, when your audience cannot complete JavaScript challenges, or when local privacy rules prohibit device linking. In those cases, stick with email invitations, staffed approval, or a queue with delayed seat confirmation. A hosted CAPTCHA is also the wrong dependency for a hard real-time admission decision; cache only a bounded, signed result and fail closed for the reservation step.
The runner-up approach is a single vendor's risk score. It is simpler to launch, and it can be reasonable for a small pilot. The trade-off is opacity: you cannot explain why a class network was challenged, tune thresholds per event, or replay a decision offline. A local reason-coded gate takes more engineering time up front, but it pays back when one abuse spike would otherwise consume a week of founder-hours.
Finish with an audit review after every event: sample allowed, stepped-up, and denied attempts; compare outcomes by provider (Google versus GitHub), network type, and event; then adjust one threshold at a time. That's enough control for a solo team without pretending any signal is magic.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://datatracker.ietf.org/doc/html/rfc6749
- https://datatracker.ietf.org/doc/html/rfc7636
- https://developers.google.com/identity/protocols/oauth2
- https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
Top comments (0)