Authentication Review Post - acdc-digital/solopro GitHub Wiki
Authentication is the backbone of any SaaS application, but it's often one of the most complex systems to get right. After building and iterating on the authentication system for SoloPro, our Next.js application with a Convex backend, I want to share what we learned about creating a secure, scalable, and maintainable auth system.
When we started building SoloPro, authentication seemed straightforward. Users sign up, they get an ID, we store their data. Simple, right?
Not quite. In a modern SaaS, authentication isn't just about identity verification—it's about:
- User correlation across services (Stripe payments, webhooks, third-party integrations)
- Security at every layer (frontend, backend, database, external APIs)
- Developer experience (consistent patterns, easy to implement correctly)
- Real-time synchronization between client and server state
- Flexible user lookup for handling external events and edge cases
We built our authentication system around Convex Auth, which provides excellent integration with the Convex real-time database. Here's how our system works:
// Backend: Always use server-side user identification
export const getUserData = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx); // Server determines identity
if (!userId) return null;
return await ctx.db
.query("userData")
.withIndex("by_userId", q => q.eq("userId", userId))
.collect();
},
});
// Frontend: Custom hook for consistent auth state
export function MyComponent() {
const { isAuthenticated, isLoading, userId } = useConvexUser();
if (isLoading) return <LoadingSpinner />;
if (!isAuthenticated || !userId) return <AuthRequired />;
return <UserContent userId={userId} />;
}
One of our biggest challenges was correlating users across different systems. When a Stripe webhook fires, how do we know which user it belongs to? Our solution uses a multi-layered approach:
// 1. Primary: Pass user ID to external services
const stripeSession = await stripe.checkout.sessions.create({
client_reference_id: userId, // Convex user ID
metadata: { userId },
// ...
});
// 2. Fallback: Flexible user lookup in webhooks
export const processStripeWebhook = mutation({
handler: async (ctx, { data }) => {
// Priority order for user identification
const userIdentifier = data.client_reference_id || // Best: Direct ID
data.metadata?.userId || // Good: Metadata
data.customer_details?.email; // Fallback: Email lookup
// Robust user resolution with multiple strategies
const userId = await resolveUser(ctx, userIdentifier, data.customer_details?.email);
},
});
Never trust the client. This principle is woven throughout our system:
// ❌ WRONG - Client can manipulate userId
export const badQuery = query({
args: { userId: v.string() },
handler: async (ctx, { userId }) => {
return await ctx.db.query("data").filter(q => q.eq("userId", userId));
},
});
// ✅ CORRECT - Server determines user identity
export const goodQuery = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return null;
return await ctx.db
.query("data")
.withIndex("by_userId", q => q.eq("userId", userId))
.collect();
},
});
This pattern prevented entire classes of security vulnerabilities and ensures users can only access their own data.
We created standardized patterns that make it nearly impossible to implement authentication incorrectly:
// Every authenticated component follows this pattern
export function AuthenticatedComponent() {
const { isAuthenticated, isLoading, userId } = useConvexUser();
if (isLoading) return <Skeleton />;
if (!isAuthenticated || !userId) return <AuthRequired />;
// Safe to use userId here
return <Content />;
}
This consistency meant new team members could implement features correctly without deep authentication knowledge.
Our Stripe integration handles the complexity of user correlation gracefully:
// Frontend: Pass user ID when creating checkout
const session = await createCheckoutSession(priceId, 'payment', true, userId);
// Webhook: Handle user correlation with fallbacks
const userId = await resolveUser(
ctx,
webhookData.client_reference_id,
webhookData.customer_details?.email
);
This approach has handled thousands of payment events without losing user correlation.
Every user-specific table includes proper indexing:
// Schema with proper indexing
userSubscriptions: defineTable({
userId: v.id("users"),
status: v.string(),
currentPeriodEnd: v.optional(v.number()),
// ...
}).index("by_userId", ["userId"]), // Essential for performance
This ensures our queries remain fast even as data grows.
Our user resolution logic, while robust, has become complex:
// Current: Multiple lookup strategies scattered across files
let userId: Id<"users"> | null = null;
try {
const user = await ctx.db.get(args.userIdOrEmail as Id<"users">);
if (user) userId = user._id;
} catch (e) {
// Try email lookup...
if (!userId && args.customerEmail) {
const userByEmail = await ctx.db
.query("users")
.filter(q => q.eq(q.field("email"), args.customerEmail))
.first();
if (userByEmail) userId = userByEmail._id;
}
// ... more fallback logic
}
Improvement: Centralize this into a single resolveUser
utility function that all modules can use consistently.
We have some fallback logic that's helpful for development but risky for production:
// In payments.ts - this fallback is concerning
if (!userId) {
console.log(`User not found, trying fallback user`);
const fallbackUser = await ctx.db.query("users").first();
if (fallbackUser) {
userId = fallbackUser._id; // 😬 Could attribute payments to wrong user
}
}
Improvement: Remove fallback logic for production or make it explicitly environment-aware.
While we prevent unauthorized access well, we lack granular permissions:
// Current: Binary access (own data or nothing)
const userId = await getAuthUserId(ctx);
return await ctx.db
.query("posts")
.withIndex("by_userId", q => q.eq("userId", userId))
.collect();
// Future: Role-based or permission-based access
const userId = await getAuthUserId(ctx);
const permissions = await getUserPermissions(ctx, userId);
if (!permissions.canReadPosts) return null;
Improvement: Implement a permission system for features requiring shared access or admin capabilities.
Our authentication checking, while consistent, is somewhat verbose:
// Current: Repeated boilerplate
export function MyComponent() {
const { isAuthenticated, isLoading, userId } = useConvexUser();
if (isLoading) return <LoadingSpinner />;
if (!isAuthenticated || !userId) return <AuthRequired />;
return <Content />;
}
Improvement: Create higher-order components or guards that abstract this pattern:
// Future: More declarative approach
export const MyComponent = withAuth(() => {
return <Content />; // userId automatically available
});
Our indexing strategy has served us well:
// Always use indexes for user filtering
.withIndex("by_userId", q => q.eq("userId", userId))
// Multiple indexes for complex queries
.index("by_userId_and_categoryId", ["userId", "categoryId"])
.index("by_userId_and_createdAt", ["userId", "createdAt"])
Result: Query performance remains consistent even with growing data.
Convex's real-time capabilities shine with authentication:
// Frontend automatically updates when user data changes
const subscription = useQuery(api.userSubscriptions.getCurrentSubscription);
const hasActiveSubscription = useQuery(api.userSubscriptions.hasActiveSubscription);
Result: UI stays in sync without manual state management.
This principle saved us multiple times. Even with our own frontend, we treat all client input as potentially malicious.
By always determining user identity on the server, we eliminated an entire class of authorization bugs.
Every authentication-related operation includes logging:
console.log("Authentication attempt", { userId, timestamp: Date.now() });
This has been invaluable for debugging and security monitoring.
If you're building a similar authentication system, here's what I'd recommend:
- Use server-side authentication exclusively for data access decisions
- Create consistent patterns that make correct implementation obvious
- Index user-specific data properly from day one
- Implement robust user correlation for external services
- Log authentication events for debugging and security
- Don't trust client-provided user IDs for security decisions
- Don't skip authentication checks in any user-specific operation
- Don't create ad-hoc user lookup logic in multiple places
- Don't ignore external service correlation complexity
- Don't forget about real user ID formats in testing
// Centralized user resolution
export async function resolveUser(
ctx: Context,
identifier: string,
fallbackEmail?: string
): Promise<Id<"users"> | null> {
// Single source of truth for user lookup logic
}
// Consistent authentication wrapper
export function withAuth<T>(
component: React.ComponentType<T & { userId: string }>
): React.ComponentType<T> {
// Handles authentication boilerplate
}
// Permission-aware queries
export const createPermissionAwareQuery = (requiredPermission: string) => ({
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
const hasPermission = await checkPermission(ctx, userId, requiredPermission);
// ...
}
});
Building authentication right is hard, but it's foundational to everything else. Our system isn't perfect, but it's served us well through thousands of users and payments without major security incidents.
The key lessons:
- Security first: Never compromise on server-side verification
- Consistency: Create patterns that make correct implementation obvious
- Flexibility: Plan for external integrations from the start
- Performance: Index your data properly from day one
- Simplicity: Resist over-engineering until you actually need complexity
Authentication is one of those systems where "boring" is beautiful. The flashy stuff happens in your features—authentication should just work, reliably and securely, in the background.
What authentication challenges have you faced in your SaaS? I'd love to hear about different approaches and lessons learned. Feel free to reach out with questions about any of these patterns or implementation details.