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 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.
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.
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.