Base44's built-in auth covers email/password signup, Google OAuth, basic email verification, opt-in per-user MFA, and JWT sessions in localStorage — but production apps must add five things on top. First, enforce email-domain verification post-signup for SSO apps; the July 2025 Wiz disclosure proved this gap. Second, wrap password reset to return identical responses regardless of whether the email exists, blocking enumeration. Third, audit MFA enrollment for admin roles via a scheduled function and downgrade non-compliant accounts. Fourth, validate session age server-side in backend functions rather than trusting client timers. Fifth, ship every auth event to an external SIEM with 90-day retention. Use Auth0 or Clerk via backend functions for any enterprise SSO requirement.
Why this matters
Authentication is the front door. Get it right, the rest of the app's security has a chance. Get it wrong, every other control is moot. Base44's built-in auth handles the basics — login, signup, OAuth — but several production-critical concerns are not built in: domain verification, MFA enforcement, session validation, audit logging. This article, written by the lead engineer at Base44Devs, covers the patterns that make Base44 auth production-grade.
The July 2025 SSO bypass disclosure is the load-bearing example throughout. The vulnerability worked because a default-permissive trust model allowed signup without verifying the user's email domain. The same class of issue is present elsewhere; the lesson is to verify, never trust. For the broader post-acquisition security context, see our timeline article.
What the platform gives you
Out of the box:
- Email/password signup and login.
- Google OAuth.
- Email verification flow (configurable per app).
- Forgot-password flow with reset link.
- Per-user MFA opt-in.
- A
Userentity withid,email,full_name,role, and a few platform fields. - Session management via JWT in
localStorage.
What it does not give you:
- Enterprise SSO (Okta, Azure AD, Ping).
- Role-level MFA enforcement.
- Server-side session-age validation.
- Email-domain enforcement for SSO apps.
- Granular permissions beyond admin/user.
- Audit logging of auth events.
You build the missing pieces in backend functions.
Pattern 1: signup with domain verification
For B2B apps where users should only be able to register with their corporate email:
// backend/functions/secureSignup.ts
export default async function handler(req: Request): Promise<Response> {
if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
const { email, password } = await req.json();
// 1. Format check
if (!isValidEmail(email)) {
return new Response(JSON.stringify({ error: "Invalid email format" }), { status: 400 });
}
// 2. Domain allow-list check
const ALLOWED_DOMAINS = (Deno.env.get("ALLOWED_SIGNUP_DOMAINS") || "")
.split(",")
.map(d => d.trim().toLowerCase());
const domain = email.split("@")[1]?.toLowerCase();
if (!ALLOWED_DOMAINS.includes(domain)) {
return new Response(JSON.stringify({ error: "Email domain not authorized" }), { status: 403 });
}
// 3. Rate limit by IP
const ip = req.headers.get("x-forwarded-for") || "unknown";
const recent = await base44.entities.SignupAttempt.list(
{ ip, created_date: { $gte: new Date(Date.now() - 60 * 60 * 1000).toISOString() } }
);
if (recent.length > 10) {
return new Response("Too many attempts", { status: 429 });
}
await base44.entities.SignupAttempt.create({ ip, email, created_date: new Date().toISOString() });
// 4. Delegate to platform signup
// ... call platform's signup, get the user back ...
// 5. Audit log
await base44.entities.AuthAuditLog.create({
event_type: "signup",
email,
ip,
timestamp: new Date().toISOString(),
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
This is the pattern that would have prevented the July 2025 SSO bypass. The platform's signup checked the app_id; this wrapper additionally checks the email domain, regardless of what the platform infers. Defense in depth.
Pattern 2: password reset that doesn't leak
Default reset endpoints often respond differently for existing vs. nonexistent emails. This is email enumeration. Fix:
// backend/functions/requestPasswordReset.ts
export default async function handler(req: Request): Promise<Response> {
const { email } = await req.json();
// Always return the same response regardless of whether the user exists
const genericResponse = JSON.stringify({
message: "If an account exists for this email, a reset link has been sent.",
});
if (!isValidEmail(email)) {
return new Response(genericResponse, { status: 200 });
}
// Internally, check if the user exists
const users = await base44.entities.User.list({ email }, null, 1);
if (users.length === 0) {
// Pretend we sent an email
return new Response(genericResponse, { status: 200 });
}
// Actually send the reset email
const resetToken = generateSecureToken();
await base44.entities.PasswordResetToken.create({
user_id: users[0].id,
token_hash: await sha256(resetToken),
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
await sendEmail({
to: email,
subject: "Reset your password",
body: `Click to reset: ${Deno.env.get("APP_URL")}/reset?token=${resetToken}`,
});
return new Response(genericResponse, { status: 200 });
}
Both branches return the same response and the same status. The attacker cannot distinguish them.
Use a real email provider (Resend, Postmark) rather than the platform's sendEmail for password reset because deliverability is a security control here — if the email lands in spam, the user is locked out.
Pattern 3: MFA enforcement for admin roles
The platform supports per-user MFA but does not enforce it. Build the enforcement:
// backend/functions/enforceAdminMFA.ts (scheduled daily)
export default async function handler(req: Request): Promise<Response> {
const admins = await base44.entities.User.list({ role: "admin" });
for (const admin of admins) {
if (admin.mfa_enrolled === true) continue;
const daysWithoutMFA = daysSince(admin.role_assigned_at);
if (daysWithoutMFA > 14) {
// Hard demote: revoke admin
await base44.entities.User.update(admin.id, {
role: "user",
notes: `Demoted: 14 days without MFA enrollment`,
});
await base44.entities.AuthAuditLog.create({
event_type: "admin_demoted_no_mfa",
user_id: admin.id,
timestamp: new Date().toISOString(),
});
continue;
}
if (daysWithoutMFA > 7) {
await base44.entities.User.update(admin.id, { role: "pending_mfa" });
await sendEmail({
to: admin.email,
subject: "Action required: enable MFA on your admin account",
body: "MFA is required for admin access. Enable within 7 days or your admin role will be revoked.",
});
continue;
}
// Days 1-7: gentle reminder
if (daysWithoutMFA % 2 === 0) {
await sendEmail({
to: admin.email,
subject: "Reminder: enable MFA on your admin account",
body: "MFA is required for admin access...",
});
}
}
return new Response("OK", { status: 200 });
}
Trigger via external scheduler. The combination of soft warnings, role downgrade, and full revocation makes MFA a hard requirement without surprising anyone.
Pattern 4: server-side session validation
Frontend session timers are advisory. A backend function should validate session age on every privileged call:
// Reusable middleware
async function requireRecentAuth(req: Request, maxAgeMinutes: number) {
const me = await base44.User.me();
if (!me) throw new AuthError(401, "Not authenticated");
const tokenIssuedAt = getTokenIssuedAt(req);
const ageMinutes = (Date.now() - tokenIssuedAt) / 60_000;
if (ageMinutes > maxAgeMinutes) {
throw new AuthError(401, "Session expired - please re-authenticate");
}
return me;
}
// Usage in a backend function
export default async function changePassword(req: Request) {
try {
const me = await requireRecentAuth(req, 5); // 5-minute fresh-auth requirement
// ... process password change ...
return new Response("OK", { status: 200 });
} catch (err) {
if (err instanceof AuthError) {
return new Response(err.message, { status: err.status });
}
throw err;
}
}
For sensitive operations (password change, email change, billing change, role change), require auth fresher than the routine session — typically the user must have logged in within the last 5 minutes. For routine operations, an 8-hour sliding window is sufficient.
Pattern 5: enterprise SSO via Auth0 or Clerk
For B2B SaaS that needs to integrate with customers' Okta, Azure AD, Google Workspace, or other IdPs:
- Configure Auth0 or Clerk to handle the SAML/OIDC negotiation with the customer's IdP.
- The user logs in on auth0.example.com and gets back an Auth0-issued JWT.
- Your frontend stores the Auth0 JWT.
- Every backend function call includes the Auth0 JWT in the Authorization header.
- Backend functions validate the Auth0 JWT signature using Auth0's public keys.
- The function maps the Auth0 user to a Base44 User entity (by email or external ID).
// backend/functions/getMyData.ts
import { jwtVerify, createRemoteJWKSet } from "https://esm.sh/jose@5";
const JWKS = createRemoteJWKSet(
new URL("https://example.auth0.com/.well-known/jwks.json")
);
export default async function handler(req: Request) {
const auth = req.headers.get("authorization");
if (!auth?.startsWith("Bearer ")) {
return new Response("Unauthorized", { status: 401 });
}
const token = auth.slice(7);
let payload;
try {
const result = await jwtVerify(token, JWKS, {
issuer: "https://example.auth0.com/",
audience: "https://api.example.com",
});
payload = result.payload;
} catch {
return new Response("Invalid token", { status: 401 });
}
const email = payload.email as string;
const userRecord = (await base44.entities.User.list({ email }, null, 1))[0];
if (!userRecord) {
// Auto-provision on first login if your policy allows
return new Response("User not provisioned", { status: 403 });
}
// ... return the user's data ...
}
This pattern keeps Base44's User entity as the source of truth for app data while delegating identity to a real auth provider. Base44's native auth becomes secondary; your real users authenticate via the IdP.
Pattern 6: audit logging
Every auth-relevant event into an AuthAuditLog entity:
| Event | Data captured |
|---|---|
| login_success | email, ip, user_agent, timestamp |
| login_failure | email, ip, user_agent, timestamp, reason |
| signup | email, ip, user_agent, timestamp |
| password_reset_requested | email, ip, timestamp |
| password_changed | user_id, ip, timestamp |
| role_changed | user_id, old_role, new_role, changed_by, timestamp |
| mfa_enrolled / mfa_disabled | user_id, timestamp |
| admin_demoted_no_mfa | user_id, timestamp |
| suspicious_activity | user_id, reason, timestamp |
Ship the log out to an external SIEM (Logflare, Axiom, Datadog) for retention. The platform's logs roll quickly; auth logs need 90+ day retention for compliance.
Set alerts on:
- More than 5 login failures from one IP in a minute.
- Signup rate above the 14-day baseline by 3x.
- Role changes outside of expected admin actions.
- MFA disabled events.
Pattern 7: secure logout
Logout should invalidate the session on the server, not just clear local storage:
// backend/functions/secureLogout.ts
export default async function handler(req: Request) {
const me = await base44.User.me();
if (!me) return new Response("OK", { status: 200 });
// Invalidate the token on the platform
await base44.auth.signOut();
// Log the logout
await base44.entities.AuthAuditLog.create({
event_type: "logout",
user_id: me.id,
timestamp: new Date().toISOString(),
});
// Optional: invalidate any refresh tokens you've issued
// ...
return new Response("OK", { status: 200 });
}
Common authentication mistakes
Trusting the platform's signup without domain verification. The July 2025 SSO bypass exploited exactly this gap.
Differential responses on password reset. Email enumeration vulnerability.
Per-user MFA opt-in without role-level enforcement. Admins forget to enable it; you have privileged accounts without MFA.
Frontend-only session timeout. Trivially bypassed.
Forgetting to invalidate tokens on logout. Stale tokens stay valid in compromised browsers. The full failure-mode catalog lives in the Base44 error reference.
No auth audit log. Post-incident forensics is impossible.
Storing passwords in plaintext. The platform doesn't, but if you handle passwords yourself for any reason, never store plaintext.
Long-lived JWTs. Increases blast radius of token theft.
Authentication checklist
- Signup verifies email domain against allow-list for SSO apps.
- Password reset returns identical responses regardless of email existence.
- MFA required for admin role; enforced via scheduled audit.
- Sessions validated server-side on privileged operations.
- Sensitive operations require fresh auth (last 5 minutes).
- Token TTL under 1 hour; rotation on privileged actions.
- Auth audit log persisted to external SIEM with 90+ day retention.
- Alerts configured on auth failure bursts and role changes.
- Logout invalidates session server-side.
- Enterprise SSO via Auth0 or Clerk if needed.
Want us to audit your auth setup?
Our $497 audit reviews your full authentication flow, tests for the SSO-bypass class of issue, verifies MFA enforcement, and checks audit log coverage. Most apps have 3–7 fixable issues. Order an audit or book a free 15-minute call.
Related reading
- Base44 Security Hardening Checklist — the broader security drill-down, of which auth is one section.
- OWASP Top 10 in Base44 — the framework that scopes A07 (Identification and Authentication Failures).
- Base44 SDK Reference — the User module and SDK calls that compose with these patterns.