Hardening a SaaS login on Cloudflare Free: Turnstile, Google sign-in and email without SMTP
Hardening a SaaS login on Cloudflare Free: Turnstile, Google sign-in and email without SMTP
SyllogOS is a multi-tenant platform I'm building for Greek cultural associations and the federations they belong to: members, boards, events, documents. Its first tenant is a regional federation, and the people logging in are volunteers, not engineers. That shaped the login more than any threat model. It has to be safe, and it has to feel safe to someone who has never heard the word "brute force".
The Budget and Tools
The budget is Cloudflare's Free plan and one VPS. Here's what sits in front of that one form, in the order a request meets it. Every layer assumes the one before it has failed.
1. A Rate Limit at the Edge
The Free plan's rate limiting is small, so I spend it on the single most attacked URL:
- Match: URI path equals
/api/auth/login - Counting: per IP
- Threshold: 3 requests in 10 seconds
- Action: block for 10 seconds
It's a speed bump, not a wall. A 10-second block barely slows a patient attacker, and anyone who can edit the zone can switch it off. That's exactly why it isn't the only layer.
2. The Real Client IP
Behind Cloudflare, every request reaches nginx from a Cloudflare edge address. Rate limit by that address and all your visitors share one bucket: one attacker locks out everyone, or nobody ever gets locked out at all.
nginx's real IP module restores the visitor's address, and the important part is whom it trusts:
set_real_ip_from 173.245.48.0/20;set_real_ip_from 103.21.244.0/22;set_real_ip_from 2400:cb00::/32;real_ip_header CF-Connecting-IP;
That's an excerpt: the full list of ranges is published at cloudflare.com/ips and should be copied completely. Never trust CF-Connecting-IP from any source, because if your origin is reachable directly, anyone can send that header with any address they like.
The app needs that address too: pass it on from nginx (proxy_set_header X-Forwarded-For $remote_addr;) and let Express trust only the local proxy (app.set('trust proxy', 'loopback')).
I didn't consider this done until the proof was in the access log: my own home IP, not a Cloudflare address.
3. Turnstile
Cloudflare Turnstile replaces a CAPTCHA with a mostly invisible check. I run it in Managed mode, and the widget gives the browser a short-lived, single-use token. The widget on its own proves nothing. A bot can skip the page and POST directly to the API.
The token only means something once your server has checked it with Cloudflare:
async function verifyTurnstile(token, ip) {
if (!token) return false;
try {
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
remoteip: ip,
}),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return false;
const data = await res.json();
return data.success === true;
} catch {
return false;
}
}
Two decisions hide in that snippet. First, it fails closed: if Cloudflare can't be reached, nobody logs in. For an admin panel that's defensible. For a shop checkout it might not be, so make that call on purpose.
Second, one widget covers the root domain and every tenant subdomain, so each tenant's page gets the site key from the API, not from a hard-coded value in the frontend. Don't forget the Content-Security-Policy. Turnstile needs https://challenges.cloudflare.com in both script-src and frame-src, or the widget silently fails to appear.
4. An Application Limiter that Doesn't Depend on the Edge
Inside the API there's a second limiter: 10 attempts a minute, then a 10-minute lock. It keeps working if the Cloudflare rule is edited, disabled, or bypassed through a direct connection to the origin.
When you combine the layers, the order of checks in the route matters:
- Limiter first: cheap, and it protects everything after it
- Turnstile next: one outbound call per attempt, so it shouldn't run for already-blocked clients
- Credentials last: the expensive password hash only runs for requests that passed both
5. Google Sign-in, without Google Sign-up
Anyone who signs in with Google inherits whatever protection their Google account already has, including two-step verification, without the platform having to build it.
What I didn't want was registration through Google. A tenant's members are added by that tenant's administrators. So the callback only accepts an address that already belongs to this tenant:
const email = profile.email?.toLowerCase();
if (!email || profile.email_verified !== true) {
return res.redirect('/login?error=google_unverified');
}
const user = await prisma.user.findFirst({
where: {
tenantId: req.tenant.id,
email,
active: true,
},
});
if (!user) {
return res.redirect('/login?error=not_a_member');
}
(Simplified: a real callback must also validate the OAuth state parameter.)
A practical trap: while the OAuth consent screen is in Testing, only listed test users can sign in. It has to be published to production before real members can use it.
6. Email without SMTP
The VPS provider blocks outbound mail ports by default. Unblocking one takes a support ticket, and I'd rather not depend on it at all. So everything the platform sends goes over HTTPS through a transactional email API (Resend, in its EU region):
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: process.env.MAIL_FROM,
to,
subject,
html,
}),
});
On the DNS side, sending lives on its own send. subdomain with its own MX and SPF records, plus a DKIM key. Those records stay DNS only in Cloudflare, and the root domain's MX records, which handle incoming mail, are never touched.
The templates are being wired up now.
Before Every Commit
Two test suites must pass before anything is committed: tenant isolation (one tenant must never reach another tenant's data) and permissions (a role must never reach routes above it).
Permission bugs don't always leak. One I fixed on this platform failed in the least obvious direction: an administrator role was effectively dead, because a parameter name was wrong and the role could never pass its own checks. It didn't leak anything. It just silently locked out the people it was meant for, which is exactly the kind of bug that users report as "the site doesn't work" and nobody connects to authorization.
The Part that Isn't Security
The login screen also shows a short progress animation on submit that names the checks, and a row of badges for the services involved. That's communication, not protection. It helps a nervous volunteer trust the form. It stops nothing.
Keep that distinction clear, especially in your own head. The protection is the six layers above, and every one of them runs whether or not anyone sees an animation.
How do you protect logins on a small budget? I'd especially like to hear from anyone who has run Turnstile in front of a login for a while: did the Managed mode ever block real users?
Comments
No comments yet. Start the discussion.