74 KiB
Central Authentication System Research Report
Generated by: Researcher Agent - Hive Mind System Date: 2025-11-25 Mission: Comprehensive research on authentication technologies and best practices for central auth system
Executive Summary
This report provides a comprehensive analysis of authentication technologies, security best practices, and architectural patterns for building a central authentication system for the Mana Universe monorepo ecosystem. The research covers modern auth libraries, PostgreSQL security patterns, credit/token system architectures, payment integration, and multi-app authentication strategies.
Key Recommendations at a Glance
- Primary Auth Solution: Better Auth (TypeScript-first, framework-agnostic)
- Database: PostgreSQL with Row-Level Security (RLS) for multi-tenancy
- ORM: Drizzle ORM (optimal for Better Auth integration)
- Payment Gateway: Stripe with Connect for marketplace features
- JWT Strategy: Short-lived access tokens (15min) + refresh token rotation
- Credit System: Double-entry ledger pattern with idempotency keys
1. Authentication Library Comparison
1.1 Better Auth
Overview:
- Modern TypeScript-first authentication framework launched in 2024
- Framework-agnostic (React, Vue, Svelte, Astro, Next.js, Nuxt, SvelteKit, Hono)
- YC-backed (Y Combinator X25)
- Recommended by Next.js, Nuxt, Astro, and other major frameworks
Key Features:
- Email/password authentication
- 50+ OAuth providers (Google, GitHub, Discord, Twitter, etc.)
- Two-factor authentication (2FA) built-in
- Passkey support (WebAuthn)
- Magic link authentication
- Organization/multi-tenant management
- Multi-session support
- Device tracking and management
- Enterprise SSO capabilities
- Custom Identity Provider (IDP) creation
Database Support:
- PostgreSQL (via Drizzle or Prisma adapters)
- MySQL
- SQLite
- MongoDB
- Automatic schema generation via CLI
- Migration support built-in
Plugin Architecture:
- Official plugins: Organization management, 2FA
- Polar plugin for payments/subscriptions integration
- Extensible plugin ecosystem
Pricing:
- 100% FREE and open-source
- No usage limits
- Self-hosted
- Full control over data
Pros:
- Comprehensive features out-of-the-box (no plugins needed for basics)
- Excellent TypeScript support with automatic type generation
- Automatic database schema generation/migration
- Framework-agnostic design
- Active development and YC backing
- Modern developer experience
- Perfect for monorepo architecture
- No vendor lock-in
Cons:
- Relatively new (2024) - less battle-tested than alternatives
- Smaller community compared to Auth.js
- Limited real-world production examples
- Documentation still growing
Best For:
- Modern TypeScript monorepos
- Teams prioritizing developer experience
- Projects needing multi-app authentication
- Cost-sensitive projects (100% free)
1.2 Auth.js (NextAuth.js)
Overview:
- Evolved from Next.js-specific to framework-agnostic
- Established open-source project
- Wide adoption in Next.js ecosystem
Key Features:
- 50+ built-in OAuth providers
- Extensive customization via callbacks
- JWT or database sessions
- Multiple database adapters (Prisma, Drizzle, Supabase)
Pricing:
- FREE and open-source
- Self-hosted
Pros:
- Battle-tested and mature
- Large community and ecosystem
- Extensive documentation and examples
- Great for OAuth provider breadth
- Works with Supabase via adapter
Cons:
- Maintenance concerns: Original developer abandoned project, one person maintaining 90% of work
- Advanced features (2FA) require custom implementation or additional packages
- More boilerplate for setup
- Less TypeScript-native than Better Auth
Best For:
- Teams already invested in Auth.js
- Projects heavily using OAuth providers
- Next.js-first applications
1.3 Supabase Auth
Overview:
- Integrated auth solution tied to Supabase ecosystem
- Built-in PostgreSQL integration with RLS
- Part of the Supabase backend-as-a-service
Key Features:
- JWT-based authentication
- Row-Level Security (RLS) integration
- OAuth providers
- Email/password authentication
- Magic links
- Phone authentication
Pricing:
- Free tier: 50,000 monthly active users
- Pro: $25/month for 100,000 users
- Includes PostgreSQL database with RLS
Pros:
- Automatic integration with PostgreSQL RLS
- Well-integrated with Supabase ecosystem
- Handles database user sync via triggers
- Good value for price
- Built-in security features
Cons:
- Critical reliability issues reported:
- Random user logouts
- No configurable session lifetime (fundamental missing feature)
- Unencrypted client-side token storage
- No 2FA on their own platform (concerning for security product)
- Vendor lock-in to Supabase ecosystem
- Poor documentation quality reported
- Unresponsive support channels
- Not ideal for custom auth requirements
Best For:
- Projects fully committed to Supabase ecosystem
- Simple auth requirements
- Budget-conscious projects with <100k users
1.4 Clerk
Overview:
- Commercial hosted authentication service
- Polished UI and developer experience
Key Features:
- Pre-built UI components
- Configurable session lifetime (up to 10 years)
- Organization management
- 2FA built-in
- Excellent Next.js integration
Pricing:
- Free tier: 10,000 monthly active users
- Business plan: $550/month for 10,000 active users
- Scales to $25,000-$60,000+ annually
Pros:
- Best-in-class developer experience
- Beautiful pre-built UI
- Excellent documentation
- Managed infrastructure
- No security expertise required
Cons:
- Prohibitively expensive for freemium/scaling apps
- Vendor lock-in
- Less control over authentication flow
- Not suitable for multi-app ecosystems with shared auth
Best For:
- Well-funded startups with budget
- Enterprise clients
- Teams wanting managed solution
1.5 Auth0
Overview:
- Enterprise identity platform
- Managed cloud service
- Comprehensive compliance features
Key Features:
- Enterprise SSO
- SAML integration
- Risk-based authentication
- Advanced security features
- Professional support
Pricing:
- Free tier: 25,000 B2C users
- Essentials: $35/month base + usage
- Scales to $30,000+ annually for enterprise
Pros:
- Enterprise-grade features
- Compliance certifications
- Professional support
- Advanced security capabilities
Cons:
- Expensive at scale
- Vendor lock-in
- Configuration happens in dashboard vs code
- Overkill for most projects
Best For:
- Large enterprises
- Projects requiring compliance certifications
- Organizations needing professional support
1.6 Comparison Matrix
| Feature | Better Auth | Auth.js | Supabase Auth | Clerk | Auth0 |
|---|---|---|---|---|---|
| Pricing | FREE | FREE | $25/mo (100k users) | $550/mo (10k users) | $35+/mo |
| Framework Support | Universal | Universal | Universal | Next.js-first | Universal |
| TypeScript-First | ✅ Yes | ⚠️ Partial | ⚠️ Partial | ✅ Yes | ⚠️ Partial |
| 2FA Built-in | ✅ Yes | ❌ No | ⚠️ Limited | ✅ Yes | ✅ Yes |
| Multi-Session | ✅ Yes | ⚠️ Custom | ⚠️ Limited | ✅ Yes | ✅ Yes |
| Multi-Tenancy | ✅ Yes | ⚠️ Custom | ⚠️ Custom | ✅ Yes | ✅ Yes |
| Auto Schema Gen | ✅ Yes | ❌ No | ✅ Yes | N/A | N/A |
| Self-Hosted | ✅ Yes | ✅ Yes | ⚠️ Hybrid | ❌ No | ❌ No |
| PostgreSQL RLS | ✅ Compatible | ✅ Compatible | ✅ Native | ❌ No | ❌ No |
| Monorepo-Friendly | ✅ Excellent | ⚠️ Good | ⚠️ Good | ⚠️ Limited | ⚠️ Limited |
| Vendor Lock-in | ✅ None | ✅ None | ⚠️ High | ⚠️ High | ⚠️ High |
| Maturity | ⚠️ New (2024) | ✅ Mature | ✅ Mature | ✅ Mature | ✅ Mature |
| Maintenance Risk | ✅ Low | ⚠️ High | ⚠️ Medium | ✅ Low | ✅ Low |
| Best For | Modern monorepos | OAuth-heavy | Supabase projects | Enterprise ($) |
Enterprise ($) |
2. PostgreSQL Security Best Practices
2.1 Authentication Methods
SCRAM-SHA-256 (Recommended)
- Most secure password-based authentication in PostgreSQL
- Uses Salted Challenge Response Authentication Mechanism
- SHA-256 hashing algorithm
- Replace MD5 immediately - MD5 is deprecated and insecure
Additional Methods
- LDAP/PAM: Centralized authentication for enterprise
- Kerberos: Strong authentication for enterprise environments
- Certificate-based: PKI authentication for services
- GSSAPI: Generic Security Services API support
Methods to AVOID
- Trust authentication: Never use in production (allows passwordless access)
- MD5: Deprecated, cryptographically broken
- Password (plaintext): Never use
2.2 Configuration Hardening
postgresql.conf
-- Network Configuration
listen_addresses = '10.0.0.5' -- Specific IP, not '*'
port = 5432 -- Consider non-standard port
-- Authentication
password_encryption = 'scram-sha-256' -- Force SCRAM
-- Connection Limits
max_connections = 100
superuser_reserved_connections = 3
-- Logging (Security Audit)
log_connections = on
log_disconnections = on
log_duration = on
log_line_prefix = '%t [%p]: user=%u,db=%d,app=%a,client=%h '
pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host all all 10.0.0.0/24 scram-sha-256
hostssl all all 0.0.0.0/0 scram-sha-256
# Reject trust method entirely
# host all all 127.0.0.1/32 trust # NEVER USE
2.3 Access Control Best Practices
Principle of Least Privilege
-- Create application-specific roles
CREATE ROLE app_user LOGIN PASSWORD 'strong_password';
CREATE ROLE app_readonly LOGIN PASSWORD 'strong_password';
-- Grant minimal permissions
GRANT CONNECT ON DATABASE myapp TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE users TO app_user;
GRANT SELECT ON TABLE users TO app_readonly;
-- Revoke public schema access
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
Avoid Shared Accounts
- Create unique roles for each user and application
- Never share database credentials across services
- Use different roles for different access levels
Password Policies
-- Set password expiration
ALTER ROLE app_user VALID UNTIL '2026-12-31';
-- Require strong passwords (enforce at application level)
-- Mix of uppercase, lowercase, numbers, special characters
-- Minimum 12-16 characters
2.4 Encryption & Transport Security
SSL/TLS for Connections
-- Enable SSL in postgresql.conf
ssl = on
ssl_cert_file = '/path/to/server.crt'
ssl_key_file = '/path/to/server.key'
ssl_ca_file = '/path/to/ca.crt'
-- Require SSL in pg_hba.conf
hostssl all all 0.0.0.0/0 scram-sha-256
Transparent Data Encryption (TDE)
- Encrypt data at rest using file system or volume encryption
- Consider pgcrypto extension for column-level encryption
2.5 Multi-Factor Authentication
PostgreSQL doesn't natively support 2FA, but you can implement it:
- Network Layer: Use VPN with MFA requirement
- SSH Tunneling: SSH with key + password/OTP
- Application Layer: Implement 2FA in application before database connection
- Identity Provider: Integrate with LDAP/AD that enforces MFA
2.6 Brute Force Protection
auth_delay Module
-- Install extension
CREATE EXTENSION auth_delay;
-- Adds delay on failed authentication attempts
-- Configured in postgresql.conf
auth_delay.milliseconds = 5000 -- 5 second delay
Connection Pooling Limits
- Use PgBouncer or similar to limit connection rates
- Implement application-level rate limiting
- Monitor failed authentication attempts
2.7 Audit & Monitoring
Essential Logging
-- Enable audit logging
log_statement = 'ddl' -- or 'all' for comprehensive logging
log_min_duration_statement = 1000 -- Log slow queries (ms)
-- Connection tracking
log_connections = on
log_disconnections = on
-- Failed attempts
log_error_verbosity = default
pgAudit Extension
CREATE EXTENSION pgaudit;
-- Configure audit logging
SET pgaudit.log = 'read, write, ddl';
SET pgaudit.log_catalog = off;
SET pgaudit.log_parameter = on;
2.8 Row-Level Security (RLS) for Multi-Tenancy
See Section 4 for comprehensive RLS patterns
Key principles:
- Enable RLS on all multi-tenant tables
- Create policies for SELECT, INSERT, UPDATE, DELETE
- Use JWT claims for tenant identification
- Test policies extensively with automated tests
2.9 Security Checklist
- Change default postgres superuser password immediately
- Use SCRAM-SHA-256 for all password authentication
- Disable trust authentication method
- Configure listen_addresses to specific IPs
- Enable SSL/TLS for all connections
- Create role-specific database users (no sharing)
- Apply principle of least privilege to all roles
- Enable connection and authentication logging
- Install and configure auth_delay extension
- Set up automated backups with encryption
- Subscribe to PostgreSQL security announcements
- Keep PostgreSQL updated to latest stable version
- Implement connection pooling with rate limits
- Configure pgAudit for compliance requirements
- Enable Row-Level Security for multi-tenant tables
- Regular security audits and penetration testing
3. JWT Security Best Practices
3.1 Token Expiration Strategy
Access Tokens
- Recommended Lifespan: 15-30 minutes
- Rationale: Short lifespan limits exposure if compromised
- Storage: Memory or httpOnly cookies (never localStorage)
Refresh Tokens
- Recommended Lifespan: 7-14 days
- Rationale: Balance between security and user experience
- Storage: httpOnly cookies (web) or secure storage (mobile)
// Example token configuration
const tokenConfig = {
accessToken: {
expiresIn: '15m',
algorithm: 'RS256',
},
refreshToken: {
expiresIn: '7d',
rotating: true, // Implement token rotation
},
};
3.2 Refresh Token Rotation
Critical Security Feature:
- Every refresh token is single-use only
- New refresh token issued with each access token refresh
- Old refresh token immediately invalidated
- Detects token theft/replay attacks
// Token rotation flow
async function refreshTokens(refreshToken: string) {
// 1. Validate refresh token
const session = await validateRefreshToken(refreshToken);
// 2. Check if token already used (replay attack detection)
if (session.refreshToken !== refreshToken) {
// Token reuse detected - revoke all user sessions
await revokeAllUserSessions(session.userId);
throw new Error('Token reuse detected');
}
// 3. Generate new token pair
const newAccessToken = generateAccessToken(session.userId);
const newRefreshToken = generateRefreshToken();
// 4. Update session with new refresh token
await updateSession(session.id, { refreshToken: newRefreshToken });
// 5. Return new tokens
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
Benefits:
- Blocks replay attacks
- Limits blast radius of stolen tokens
- Simplifies session management
- Industry standard in 2025
3.3 Algorithm Selection
RS256 (Recommended for Production)
- Asymmetric encryption (public/private key pair)
- Public key can verify tokens without risk
- Private key held securely by auth server only
- Best for: Microservices, distributed systems, multi-app ecosystems
// RS256 configuration
const jwtConfig = {
algorithm: 'RS256',
privateKey: fs.readFileSync('private.key'),
publicKey: fs.readFileSync('public.key'),
issuer: 'manacore-auth',
audience: ['manacore', 'maerchenzauber', 'memoro', 'picture'],
};
HS256 (Acceptable for Monoliths)
- Symmetric encryption (single secret key)
- Same key for signing and verification
- Simpler setup
- Risk: Any service with the key can create valid tokens
NEVER USE
- None algorithm: Allows unsigned tokens (security disaster)
- Weak algorithms: HS256 with weak secrets
3.4 Claims Validation
Standard Claims (MUST Validate)
interface StandardClaims {
iss: string; // Issuer - must match your auth server
sub: string; // Subject - user ID
aud: string | string[]; // Audience - target application(s)
exp: number; // Expiration time - MUST validate
iat: number; // Issued at - detect old tokens
nbf?: number; // Not before - prevent premature use
jti?: string; // JWT ID - for revocation tracking
}
// Validation example
function validateToken(token: string) {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'manacore-auth',
audience: currentApp,
clockTolerance: 30, // 30 seconds for clock skew
});
// Additional validation
if (decoded.iat && decoded.iat > Date.now() / 1000) {
throw new Error('Token issued in the future');
}
return decoded;
}
Custom Claims Best Practices
The 90/10 Rule:
- Only include frequently used claims (90% of requests need them)
- Keep claims minimal to reduce token size
- Avoid sensitive data in JWT payload
// Good: Minimal custom claims
interface AppClaims extends StandardClaims {
app_id: string; // Which app is this for?
role: 'user' | 'admin'; // User role
tenant_id?: string; // For multi-tenant apps
}
// Bad: Too much data
interface BadClaims extends StandardClaims {
email: string; // PII - not needed in most requests
full_name: string; // PII - fetch from database when needed
address: object; // Large object - bloats token
preferences: object; // Rarely needed - fetch separately
profile_pic: string; // Large data - serve via CDN
}
3.5 app_metadata vs user_metadata
Pattern from Auth0/Supabase:
// app_metadata: System-controlled, security-sensitive
interface AppMetadata {
role: 'user' | 'admin' | 'moderator';
tenant_id: string;
subscription: 'free' | 'pro' | 'enterprise';
credits: number;
flags: string[]; // Feature flags
}
// user_metadata: User-controlled, non-sensitive
interface UserMetadata {
display_name: string;
avatar_url: string;
preferences: {
theme: 'light' | 'dark';
language: string;
};
}
// In JWT, only include security-critical app_metadata
interface JWTPayload {
sub: string;
app_metadata: {
role: string;
tenant_id: string;
};
// user_metadata fetched from database when needed
}
Namespacing Custom Claims:
// Use namespaced claims to avoid conflicts
interface NamespacedClaims {
'https://manacore.ai/app_id': string;
'https://manacore.ai/role': string;
'https://manacore.ai/tenant_id': string;
}
3.6 Storage Best Practices
❌ NEVER Store in localStorage
// VULNERABLE TO XSS ATTACKS
localStorage.setItem('token', accessToken); // DON'T DO THIS
✅ Web Applications
// Option 1: httpOnly cookies (best for web)
res.cookie('accessToken', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
});
// Option 2: Memory only (for SPA)
// Store in closure or React state, lost on refresh
let accessToken = null;
✅ Mobile Applications
// Expo SecureStore (encrypted storage)
import * as SecureStore from 'expo-secure-store';
await SecureStore.setItemAsync('accessToken', token);
const token = await SecureStore.getItemAsync('accessToken');
3.7 Transport Security
// ONLY send tokens over HTTPS
if (window.location.protocol !== 'https:' && !isDevelopment) {
throw new Error('JWT must be transmitted over HTTPS');
}
// Include in Authorization header
const headers = {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
// NEVER include in URL query parameters
// ❌ BAD: https://api.example.com/users?token=eyJ...
// ✅ GOOD: Authorization: Bearer eyJ...
3.8 Token Revocation Strategy
JWTs are stateless, making revocation challenging. Solutions:
1. Short Expiration + Refresh Tokens
- Access tokens expire quickly (15min)
- Revoke refresh tokens in database
- Compromised access token only valid briefly
2. Token Blacklist (jti claim)
// Add unique ID to each token
const token = jwt.sign(
{ sub: userId, jti: uuid() },
privateKey
);
// Check blacklist on each request
async function validateToken(token: string) {
const decoded = jwt.verify(token, publicKey);
const isBlacklisted = await redis.exists(`blacklist:${decoded.jti}`);
if (isBlacklisted) {
throw new Error('Token revoked');
}
return decoded;
}
// Revoke token
async function revokeToken(jti: string, expiresAt: Date) {
const ttl = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
await redis.setex(`blacklist:${jti}`, ttl, '1');
}
3. Session Table (Hybrid Approach)
CREATE TABLE sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
refresh_token TEXT NOT NULL,
access_token_jti TEXT,
device_info JSONB,
ip_address INET,
created_at TIMESTAMP DEFAULT NOW(),
last_used_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN DEFAULT FALSE
);
-- Quick revocation check
CREATE INDEX idx_sessions_jti ON sessions(access_token_jti) WHERE revoked = FALSE;
3.9 Security Checklist
- Use RS256 algorithm for distributed systems
- Set access token expiration to 15-30 minutes
- Implement refresh token rotation
- Validate all standard claims (iss, aud, exp, iat, nbf)
- Store tokens in httpOnly cookies (web) or secure storage (mobile)
- NEVER store tokens in localStorage
- Transmit tokens only over HTTPS
- Keep JWT payload minimal (90/10 rule)
- Namespace custom claims to avoid conflicts
- Implement token revocation strategy
- Monitor for token reuse (replay attacks)
- Include device fingerprinting for suspicious activity
- Set up automated token cleanup/expiration
4. PostgreSQL Row-Level Security (RLS) for Multi-Tenancy
4.1 Overview
Row-Level Security (RLS) is a PostgreSQL feature (since 9.5) that allows fine-grained access control at the row level. It's essential for multi-tenant SaaS applications.
Key Benefit: Defense in depth - even if application code has bugs, database won't return data outside tenant scope.
4.2 Basic RLS Setup
-- 1. Create multi-tenant table
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 2. Enable RLS on table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 3. Create policy for SELECT
CREATE POLICY tenant_isolation_select ON posts
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- 4. Create policy for INSERT
CREATE POLICY tenant_isolation_insert ON posts
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- 5. Create policy for UPDATE
CREATE POLICY tenant_isolation_update ON posts
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- 6. Create policy for DELETE
CREATE POLICY tenant_isolation_delete ON posts
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
4.3 Setting Tenant Context from JWT
// Application code (NestJS example)
export class TenantMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
// 1. Extract JWT from request
const token = req.headers.authorization?.replace('Bearer ', '');
// 2. Verify and decode JWT
const decoded = jwt.verify(token, publicKey);
// 3. Set tenant context for database queries
await this.dataSource.query(
`SET LOCAL app.current_tenant_id = $1`,
[decoded.tenant_id]
);
// 4. Store in request for application use
req.tenantId = decoded.tenant_id;
req.userId = decoded.sub;
next();
}
}
// Alternative: Set at connection level for entire session
async function createTenantConnection(tenantId: string) {
const connection = await pool.connect();
await connection.query(`SET app.current_tenant_id = $1`, [tenantId]);
return connection;
}
4.4 RLS with Supabase Integration
-- Supabase automatically sets auth.uid() from JWT
-- Use auth.uid() for user-level isolation
CREATE POLICY user_own_data ON posts
FOR ALL
USING (user_id = auth.uid());
-- Combine with tenant isolation
CREATE POLICY tenant_and_user_isolation ON posts
FOR ALL
USING (
tenant_id = current_setting('app.current_tenant_id', true)::UUID
AND user_id = auth.uid()
);
4.5 Advanced RLS Patterns
Role-Based Access Control (RBAC)
-- Create roles table
CREATE TABLE tenant_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
UNIQUE(tenant_id, user_id)
);
-- Enable RLS on tenant_users
ALTER TABLE tenant_users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_users_select ON tenant_users
FOR SELECT
USING (user_id = auth.uid());
-- Policy allowing admins to see all posts, members only their own
CREATE POLICY posts_rbac ON posts
FOR SELECT
USING (
tenant_id = current_setting('app.current_tenant_id', true)::UUID
AND (
-- User is admin or owner
EXISTS (
SELECT 1 FROM tenant_users
WHERE tenant_id = posts.tenant_id
AND user_id = auth.uid()
AND role IN ('owner', 'admin')
)
-- OR user owns the post
OR user_id = auth.uid()
)
);
Shared Resources Across Tenants
-- Some resources might be shared (e.g., public templates)
CREATE TABLE templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID, -- NULL for public templates
name TEXT NOT NULL,
is_public BOOLEAN DEFAULT FALSE
);
CREATE POLICY templates_access ON templates
FOR SELECT
USING (
is_public = TRUE -- Public templates accessible to all
OR tenant_id = current_setting('app.current_tenant_id', true)::UUID
);
4.6 Performance Considerations
Indexing for RLS
-- CRITICAL: Index tenant_id for RLS performance
CREATE INDEX idx_posts_tenant_id ON posts(tenant_id);
-- Composite indexes for common query patterns
CREATE INDEX idx_posts_tenant_user ON posts(tenant_id, user_id);
CREATE INDEX idx_posts_tenant_created ON posts(tenant_id, created_at DESC);
Policy Combining (OR vs AND)
-- By default, multiple policies are combined with OR
-- Policy 1: User can see their own posts
CREATE POLICY user_own_posts ON posts
FOR SELECT
USING (user_id = auth.uid());
-- Policy 2: Admins can see all tenant posts
CREATE POLICY admin_all_posts ON posts
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM tenant_users
WHERE tenant_id = posts.tenant_id
AND user_id = auth.uid()
AND role = 'admin'
)
);
-- Result: User sees their posts OR all posts if admin (OR logic)
4.7 Testing RLS Policies
CRITICAL: Extensively test RLS policies to prevent data leaks.
// Integration test example
describe('RLS Tenant Isolation', () => {
it('should not allow user from tenant A to see tenant B data', async () => {
// Create data for tenant A
const tenantAUser = await createUser({ tenantId: 'tenant-a' });
const tenantAPost = await createPost({
tenantId: 'tenant-a',
userId: tenantAUser.id
});
// Create user for tenant B
const tenantBUser = await createUser({ tenantId: 'tenant-b' });
// Authenticate as tenant B user
const tenantBToken = generateToken(tenantBUser);
// Attempt to access tenant A data
const response = await request(app)
.get(`/posts/${tenantAPost.id}`)
.set('Authorization', `Bearer ${tenantBToken}`);
// Should be forbidden or not found
expect(response.status).toBe(404); // Or 403
});
it('should allow admin to see all tenant posts', async () => {
const admin = await createUser({ tenantId: 'tenant-a', role: 'admin' });
const member = await createUser({ tenantId: 'tenant-a', role: 'member' });
const memberPost = await createPost({
tenantId: 'tenant-a',
userId: member.id
});
const adminToken = generateToken(admin);
const response = await request(app)
.get(`/posts/${memberPost.id}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(response.status).toBe(200);
});
});
4.8 Views and RLS Bypass Risk
CRITICAL SECURITY WARNING:
-- Views can bypass RLS if owned by superuser!
CREATE VIEW all_posts AS SELECT * FROM posts;
-- If view owner has BYPASSRLS privilege, RLS is ignored
ALTER VIEW all_posts OWNER TO postgres; -- DANGEROUS
-- Solution: Views should be owned by role without BYPASSRLS
CREATE ROLE app_viewer;
ALTER VIEW all_posts OWNER TO app_viewer;
-- Or use security barrier views
CREATE VIEW safe_posts WITH (security_barrier) AS
SELECT * FROM posts;
4.9 RLS Best Practices
- Enable RLS on ALL multi-tenant tables
- Create policies for SELECT, INSERT, UPDATE, DELETE separately
- Index tenant_id columns for performance
- Use runtime configuration variables for tenant context
- Combine RLS with application-level validation (defense in depth)
- Test policies extensively with integration tests
- Avoid views owned by superuser (bypasses RLS)
- Use security barrier views when needed
- Monitor RLS policy performance
- Document policies and their intended behavior
- Regular security audits of RLS policies
- Use connection pooling with proper tenant context setting
5. Credit/Token System Architecture
5.1 Core Requirements
A robust credit system for multi-app ecosystems needs:
- Accuracy: Financial precision (no rounding errors)
- Auditability: Complete transaction history
- Idempotency: No duplicate charges
- Atomicity: All-or-nothing transactions
- Performance: Fast balance checks
- Security: Prevent unauthorized access
- Scalability: Handle high transaction volume
5.2 Database Schema - Ledger Pattern
Double-Entry Accounting is the gold standard for financial systems.
-- 1. Accounts table (user wallets)
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
account_type TEXT NOT NULL CHECK (account_type IN ('credit', 'bonus', 'pending')),
balance DECIMAL(20, 2) NOT NULL DEFAULT 0 CHECK (balance >= 0),
currency TEXT NOT NULL DEFAULT 'USD',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, account_type, currency)
);
-- 2. Transactions table (ledger)
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
idempotency_key TEXT UNIQUE NOT NULL, -- Critical for preventing duplicates
transaction_type TEXT NOT NULL CHECK (
transaction_type IN (
'credit_purchase', 'credit_usage', 'bonus_grant',
'refund', 'adjustment', 'expiration'
)
),
amount DECIMAL(20, 2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'pending' CHECK (
status IN ('pending', 'completed', 'failed', 'reversed')
),
metadata JSONB, -- Store app_id, feature_id, stripe_payment_id, etc.
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
expires_at TIMESTAMP,
-- Double-entry references
debit_account_id UUID REFERENCES accounts(id),
credit_account_id UUID REFERENCES accounts(id),
-- Audit trail
created_by UUID REFERENCES auth.users(id),
notes TEXT
);
-- 3. Transaction entries (double-entry records)
CREATE TABLE transaction_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES accounts(id),
entry_type TEXT NOT NULL CHECK (entry_type IN ('debit', 'credit')),
amount DECIMAL(20, 2) NOT NULL,
balance_after DECIMAL(20, 2) NOT NULL, -- Snapshot for auditing
created_at TIMESTAMP DEFAULT NOW()
);
-- 4. Indexes
CREATE INDEX idx_accounts_user ON accounts(user_id);
CREATE INDEX idx_transactions_user ON transactions(user_id);
CREATE INDEX idx_transactions_status ON transactions(status);
CREATE INDEX idx_transactions_idempotency ON transactions(idempotency_key);
CREATE INDEX idx_entries_transaction ON transaction_entries(transaction_id);
CREATE INDEX idx_entries_account ON transaction_entries(account_id);
5.3 Idempotency Implementation
Critical for preventing duplicate charges:
// Client generates idempotency key
import { v4 as uuidv4 } from 'uuid';
async function purchaseCredits(userId: string, amount: number) {
const idempotencyKey = uuidv4(); // Or use request ID
return await fetch('/api/credits/purchase', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ amount }),
});
}
// Server-side handler
async function handleCreditPurchase(
userId: string,
amount: number,
idempotencyKey: string
) {
// 1. Check if transaction already exists
const existing = await db.query(
'SELECT * FROM transactions WHERE idempotency_key = $1',
[idempotencyKey]
);
if (existing.rows.length > 0) {
// Transaction already processed - return existing result
return {
status: existing.rows[0].status,
transactionId: existing.rows[0].id,
cached: true,
};
}
// 2. Begin database transaction
await db.query('BEGIN');
try {
// 3. Create transaction record (locks via idempotency_key UNIQUE)
const transaction = await db.query(
`INSERT INTO transactions (
user_id, idempotency_key, transaction_type,
amount, status
) VALUES ($1, $2, 'credit_purchase', $3, 'pending')
RETURNING *`,
[userId, idempotencyKey, amount]
);
// 4. Process Stripe payment
const payment = await stripe.paymentIntents.create({
amount: amount * 100, // Stripe uses cents
currency: 'usd',
metadata: {
transaction_id: transaction.rows[0].id,
user_id: userId,
},
});
// 5. Update transaction status
await db.query(
'UPDATE transactions SET status = $1, completed_at = NOW() WHERE id = $2',
['completed', transaction.rows[0].id]
);
// 6. Credit user account
await creditUserAccount(userId, amount, transaction.rows[0].id);
// 7. Commit
await db.query('COMMIT');
return {
status: 'completed',
transactionId: transaction.rows[0].id,
};
} catch (error) {
// 8. Rollback on error
await db.query('ROLLBACK');
// Mark transaction as failed
await db.query(
'UPDATE transactions SET status = $1 WHERE idempotency_key = $2',
['failed', idempotencyKey]
);
throw error;
}
}
5.4 Double-Entry Bookkeeping
// Credit user account (double-entry)
async function creditUserAccount(
userId: string,
amount: number,
transactionId: string
) {
await db.query('BEGIN');
try {
// 1. Get or create user credit account
const account = await db.query(
`INSERT INTO accounts (user_id, account_type, balance)
VALUES ($1, 'credit', 0)
ON CONFLICT (user_id, account_type, currency)
DO UPDATE SET updated_at = NOW()
RETURNING *`,
[userId]
);
// 2. Lock account for update
await db.query(
'SELECT * FROM accounts WHERE id = $1 FOR UPDATE',
[account.rows[0].id]
);
// 3. Update balance
const newBalance = await db.query(
`UPDATE accounts
SET balance = balance + $1, updated_at = NOW()
WHERE id = $2
RETURNING balance`,
[amount, account.rows[0].id]
);
// 4. Create credit entry
await db.query(
`INSERT INTO transaction_entries (
transaction_id, account_id, entry_type, amount, balance_after
) VALUES ($1, $2, 'credit', $3, $4)`,
[transactionId, account.rows[0].id, amount, newBalance.rows[0].balance]
);
// 5. Create corresponding debit entry (from system account)
const systemAccount = await getSystemAccount();
await db.query(
`INSERT INTO transaction_entries (
transaction_id, account_id, entry_type, amount, balance_after
) VALUES ($1, $2, 'debit', $3, 0)`,
[transactionId, systemAccount.id, amount]
);
await db.query('COMMIT');
return newBalance.rows[0].balance;
} catch (error) {
await db.query('ROLLBACK');
throw error;
}
}
// Debit user account (use credits)
async function debitUserAccount(
userId: string,
amount: number,
appId: string,
featureId: string
) {
const idempotencyKey = `${userId}-${appId}-${featureId}-${Date.now()}`;
await db.query('BEGIN');
try {
// 1. Check sufficient balance
const account = await db.query(
'SELECT * FROM accounts WHERE user_id = $1 AND account_type = $2 FOR UPDATE',
[userId, 'credit']
);
if (!account.rows.length || account.rows[0].balance < amount) {
throw new Error('Insufficient credits');
}
// 2. Create transaction
const transaction = await db.query(
`INSERT INTO transactions (
user_id, idempotency_key, transaction_type, amount,
status, metadata
) VALUES ($1, $2, 'credit_usage', $3, 'completed', $4)
RETURNING *`,
[
userId,
idempotencyKey,
amount,
JSON.stringify({ app_id: appId, feature_id: featureId })
]
);
// 3. Debit account
const newBalance = await db.query(
`UPDATE accounts
SET balance = balance - $1, updated_at = NOW()
WHERE id = $2
RETURNING balance`,
[amount, account.rows[0].id]
);
// 4. Create debit entry
await db.query(
`INSERT INTO transaction_entries (
transaction_id, account_id, entry_type, amount, balance_after
) VALUES ($1, $2, 'debit', $3, $4)`,
[transaction.rows[0].id, account.rows[0].id, amount, newBalance.rows[0].balance]
);
await db.query('COMMIT');
return {
success: true,
newBalance: newBalance.rows[0].balance,
transactionId: transaction.rows[0].id,
};
} catch (error) {
await db.query('ROLLBACK');
throw error;
}
}
5.5 Balance Calculation Strategies
Strategy 1: Cached Balance (Recommended)
-- Store balance in accounts table
-- Update on each transaction
-- Fast reads, consistent with double-entry
SELECT balance FROM accounts
WHERE user_id = $1 AND account_type = 'credit';
Strategy 2: Calculated Balance
-- Calculate from transaction entries
-- Slower but always accurate
-- Good for auditing
SELECT
COALESCE(SUM(
CASE
WHEN entry_type = 'credit' THEN amount
WHEN entry_type = 'debit' THEN -amount
END
), 0) as balance
FROM transaction_entries te
JOIN transactions t ON te.transaction_id = t.id
WHERE t.user_id = $1 AND t.status = 'completed';
Strategy 3: Hybrid (Best Practice)
// Use cached balance for speed
// Periodically verify against calculated balance
async function verifyAccountBalance(userId: string) {
const cached = await getCachedBalance(userId);
const calculated = await calculateBalance(userId);
if (Math.abs(cached - calculated) > 0.01) {
// Balance mismatch - alert and reconcile
await alertBalanceMismatch(userId, cached, calculated);
await reconcileBalance(userId, calculated);
}
}
5.6 Credit Types & Expiration
-- Support multiple credit types
CREATE TABLE credit_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
expires_after INTERVAL, -- e.g., '90 days'
priority INTEGER DEFAULT 0, -- Lower priority used first
can_refund BOOLEAN DEFAULT TRUE
);
-- Track expiration per credit batch
CREATE TABLE credit_balances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
credit_type_id UUID NOT NULL REFERENCES credit_types(id),
amount DECIMAL(20, 2) NOT NULL,
remaining DECIMAL(20, 2) NOT NULL,
granted_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP,
transaction_id UUID REFERENCES transactions(id)
);
-- Use credits with expiration priority
CREATE FUNCTION use_credits(
p_user_id UUID,
p_amount DECIMAL
) RETURNS VOID AS $$
DECLARE
v_credit_balance RECORD;
v_remaining DECIMAL := p_amount;
v_to_deduct DECIMAL;
BEGIN
-- Use credits in priority order (expiring first)
FOR v_credit_balance IN
SELECT * FROM credit_balances
WHERE user_id = p_user_id
AND remaining > 0
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY
CASE WHEN expires_at IS NULL THEN 1 ELSE 0 END, -- Non-expiring last
expires_at ASC, -- Expiring soon first
granted_at ASC -- Older first
FOR UPDATE
LOOP
EXIT WHEN v_remaining <= 0;
v_to_deduct := LEAST(v_credit_balance.remaining, v_remaining);
UPDATE credit_balances
SET remaining = remaining - v_to_deduct
WHERE id = v_credit_balance.id;
v_remaining := v_remaining - v_to_deduct;
END LOOP;
IF v_remaining > 0 THEN
RAISE EXCEPTION 'Insufficient credits';
END IF;
END;
$$ LANGUAGE plpgsql;
5.7 Refunds & Reversals
async function refundTransaction(
transactionId: string,
reason: string,
partialAmount?: number
) {
await db.query('BEGIN');
try {
// 1. Get original transaction
const original = await db.query(
'SELECT * FROM transactions WHERE id = $1 FOR UPDATE',
[transactionId]
);
if (!original.rows.length) {
throw new Error('Transaction not found');
}
if (original.rows[0].status !== 'completed') {
throw new Error('Can only refund completed transactions');
}
const refundAmount = partialAmount || original.rows[0].amount;
// 2. Process Stripe refund
if (original.rows[0].metadata.stripe_payment_id) {
await stripe.refunds.create({
payment_intent: original.rows[0].metadata.stripe_payment_id,
amount: Math.round(refundAmount * 100),
reason: 'requested_by_customer',
});
}
// 3. Create reversal transaction
const reversal = await db.query(
`INSERT INTO transactions (
user_id, idempotency_key, transaction_type, amount,
status, metadata
) VALUES ($1, $2, 'refund', $3, 'completed', $4)
RETURNING *`,
[
original.rows[0].user_id,
`refund-${transactionId}-${Date.now()}`,
refundAmount,
JSON.stringify({
original_transaction_id: transactionId,
reason
})
]
);
// 4. Reverse account entries
await debitUserAccount(
original.rows[0].user_id,
refundAmount,
reversal.rows[0].id
);
// 5. Mark original transaction as reversed
await db.query(
`UPDATE transactions
SET status = 'reversed',
metadata = metadata || $1
WHERE id = $2`,
[
JSON.stringify({ reversal_transaction_id: reversal.rows[0].id }),
transactionId
]
);
await db.query('COMMIT');
return reversal.rows[0];
} catch (error) {
await db.query('ROLLBACK');
throw error;
}
}
5.8 Credit System Best Practices
- Use DECIMAL for monetary values (never FLOAT)
- Implement idempotency for all financial transactions
- Use database transactions (BEGIN/COMMIT/ROLLBACK)
- Lock accounts during balance updates (SELECT FOR UPDATE)
- Store complete audit trail in transaction_entries
- Implement double-entry bookkeeping pattern
- Support multiple credit types (paid, bonus, promotional)
- Handle credit expiration gracefully
- Provide refund/reversal capabilities
- Monitor balance consistency (cached vs calculated)
- Use meaningful idempotency keys
- Store rich metadata (app_id, feature_id, user context)
- Implement spending limits and rate limiting
- Provide detailed transaction history API
- Set up alerts for unusual activity
- Regular reconciliation and auditing
6. Payment Integration (Stripe)
6.1 Stripe Integration Options
Option 1: Direct Stripe Integration
- Simple credit purchases
- Single merchant (Mana Core)
- Best for: Centralized credit system
Option 2: Stripe Connect
- Marketplace model
- Multiple payees (if apps pay different parties)
- Best for: Revenue sharing, marketplace features
Recommendation: Start with Option 1 (direct), upgrade to Connect if needed.
6.2 Stripe Setup for Credit Purchases
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-11-20.acacia',
});
// Create payment intent
async function createCreditPurchaseIntent(
userId: string,
amount: number, // Amount in USD
quantity: number // Number of credits
) {
// Generate idempotency key
const idempotencyKey = `purchase-${userId}-${Date.now()}`;
// Create payment intent
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Stripe uses cents
currency: 'usd',
automatic_payment_methods: {
enabled: true, // Enable all payment methods
},
metadata: {
user_id: userId,
credit_quantity: quantity,
idempotency_key: idempotencyKey,
},
}, {
idempotencyKey, // Stripe-level idempotency
});
// Store pending transaction
await db.query(
`INSERT INTO transactions (
user_id, idempotency_key, transaction_type, amount,
status, metadata
) VALUES ($1, $2, 'credit_purchase', $3, 'pending', $4)`,
[
userId,
idempotencyKey,
quantity,
JSON.stringify({
stripe_payment_intent_id: paymentIntent.id,
amount_usd: amount
})
]
);
return {
clientSecret: paymentIntent.client_secret,
idempotencyKey,
};
}
6.3 Webhook Handling
CRITICAL: Use webhooks for reliable payment confirmation.
import { buffer } from 'micro';
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).end();
}
const buf = await buffer(req);
const sig = req.headers['stripe-signature'];
let event;
try {
// Verify webhook signature
event = stripe.webhooks.constructEvent(
buf,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailure(event.data.object);
break;
case 'charge.refunded':
await handleRefund(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.json({ received: true });
}
async function handlePaymentSuccess(paymentIntent) {
const { user_id, credit_quantity, idempotency_key } = paymentIntent.metadata;
await db.query('BEGIN');
try {
// 1. Update transaction status
await db.query(
`UPDATE transactions
SET status = 'completed', completed_at = NOW()
WHERE idempotency_key = $1`,
[idempotency_key]
);
// 2. Credit user account
await creditUserAccount(
user_id,
parseFloat(credit_quantity),
idempotency_key
);
// 3. Send confirmation email
await sendPurchaseConfirmationEmail(user_id, credit_quantity);
await db.query('COMMIT');
} catch (error) {
await db.query('ROLLBACK');
console.error('Error processing payment success:', error);
// Alert team for manual intervention
await alertPaymentProcessingError(paymentIntent.id, error);
}
}
async function handlePaymentFailure(paymentIntent) {
const { idempotency_key } = paymentIntent.metadata;
await db.query(
`UPDATE transactions
SET status = 'failed',
metadata = metadata || $1
WHERE idempotency_key = $2`,
[
JSON.stringify({
failure_reason: paymentIntent.last_payment_error?.message
}),
idempotency_key
]
);
}
6.4 Dynamic Pricing & Credit Packages
// Define credit packages
const creditPackages = [
{ id: 'starter', credits: 100, price: 9.99, savings: 0 },
{ id: 'plus', credits: 500, price: 39.99, savings: 20 },
{ id: 'pro', credits: 1000, price: 69.99, savings: 30 },
{ id: 'enterprise', credits: 5000, price: 299.99, savings: 40 },
];
// Create Stripe product and prices (one-time setup)
async function setupStripeProducts() {
for (const pkg of creditPackages) {
const product = await stripe.products.create({
name: `${pkg.credits} Credits`,
description: `Purchase ${pkg.credits} credits${pkg.savings ? ` (${pkg.savings}% savings)` : ''}`,
metadata: {
credit_quantity: pkg.credits,
package_id: pkg.id,
},
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: Math.round(pkg.price * 100),
currency: 'usd',
});
console.log(`Created package ${pkg.id}: ${price.id}`);
}
}
// Purchase specific package
async function purchasePackage(userId: string, packageId: string) {
const pkg = creditPackages.find(p => p.id === packageId);
if (!pkg) throw new Error('Invalid package');
return await createCreditPurchaseIntent(userId, pkg.price, pkg.credits);
}
6.5 Payment Method Management
// Save payment method for future use
async function savePaymentMethod(userId: string, paymentMethodId: string) {
// Create or get Stripe customer
let customer = await getStripeCustomer(userId);
if (!customer) {
const user = await getUser(userId);
customer = await stripe.customers.create({
email: user.email,
metadata: { user_id: userId },
});
await saveStripeCustomerId(userId, customer.id);
}
// Attach payment method to customer
await stripe.paymentMethods.attach(paymentMethodId, {
customer: customer.id,
});
// Set as default
await stripe.customers.update(customer.id, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
}
// Quick purchase with saved payment method
async function quickPurchase(userId: string, packageId: string) {
const customer = await getStripeCustomer(userId);
const pkg = creditPackages.find(p => p.id === packageId);
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(pkg.price * 100),
currency: 'usd',
customer: customer.id,
payment_method: customer.invoice_settings.default_payment_method,
off_session: true,
confirm: true,
metadata: {
user_id: userId,
credit_quantity: pkg.credits,
},
});
return paymentIntent;
}
6.6 Subscription Model (Optional)
// Monthly credit subscription
async function createCreditSubscription(
userId: string,
monthlyCredits: number,
price: number
) {
const customer = await getStripeCustomer(userId);
// Create subscription
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{
price: await getOrCreateSubscriptionPrice(monthlyCredits, price),
}],
metadata: {
user_id: userId,
monthly_credits: monthlyCredits,
},
});
// Store subscription in database
await db.query(
`INSERT INTO subscriptions (
user_id, stripe_subscription_id, monthly_credits, status
) VALUES ($1, $2, $3, $4)`,
[userId, subscription.id, monthlyCredits, subscription.status]
);
return subscription;
}
// Handle subscription renewal webhook
async function handleSubscriptionRenewal(subscription) {
const { user_id, monthly_credits } = subscription.metadata;
// Grant monthly credits
await grantMonthlyCredits(user_id, parseInt(monthly_credits));
}
6.7 Stripe Best Practices
- Always verify webhook signatures
- Use idempotency keys for all operations
- Store Stripe customer ID in user table
- Handle all relevant webhook events
- Implement retry logic for failed webhooks
- Never trust client-side payment amounts
- Use metadata extensively for context
- Test with Stripe test mode thoroughly
- Implement proper error handling
- Log all payment-related events
- Set up Stripe dashboard alerts
- Monitor for fraudulent activity
- Provide clear refund policy
- Support multiple payment methods
- Keep Stripe SDK updated
7. Multi-App Authentication Patterns
7.1 Architecture Overview
Centralized Auth Server Pattern:
┌─────────────────────────────────────────────────────────────┐
│ Mana Core Auth Service │
│ - Issues: manaToken, appToken, refreshToken │
│ - Single source of truth for users │
│ - Manages sessions, devices, 2FA │
│ - Credit system integrated │
└────────────────┬────────────────────────────────────────────┘
│
│ JWT (RS256)
│
┌────────────┴────────────┬───────────────┬────────────┐
│ │ │ │
┌───▼───────┐ ┌───────▼────┐ ┌────▼─────┐ ┌──▼──────┐
│Maerchen- │ │ Memoro │ │ Picture │ │ Chat │
│ zauber │ │ │ │ │ │ │
└───────────┘ └────────────┘ └──────────┘ └─────────┘
- Validates JWT - Validates JWT - Validates - Validates
- Checks app_id - RLS policies - RLS - RLS
- Uses credits - Uses credits - Credits - Credits
7.2 Token Types in Mana Ecosystem
1. manaToken
- Purpose: Universal authentication across all Mana apps
- Payload:
interface ManaToken {
sub: string; // user_id
iss: 'manacore-auth';
aud: ['manacore', 'maerchenzauber', 'memoro', 'picture', 'chat'];
exp: number; // 15 minutes
iat: number;
role: 'user' | 'admin';
credits: number; // Current credit balance
}
2. appToken (Supabase-compatible)
- Purpose: App-specific token for Supabase RLS
- Payload:
interface AppToken {
sub: string; // user_id
iss: 'manacore-auth';
aud: 'maerchenzauber'; // Single app
app_id: 'maerchenzauber';
exp: number;
iat: number;
role: 'authenticated';
// App-specific claims
tenant_id?: string;
}
3. refreshToken
- Purpose: Long-lived token for renewing access
- Storage: Database, single-use with rotation
- No payload: Opaque token looked up in database
7.3 Authentication Flow
// 1. User login
async function login(email: string, password: string) {
// Validate credentials
const user = await validateCredentials(email, password);
// Generate token trio
const manaToken = generateManaToken(user);
const refreshToken = generateRefreshToken();
// Store session
await db.query(
`INSERT INTO sessions (
user_id, refresh_token, device_info, ip_address, expires_at
) VALUES ($1, $2, $3, $4, NOW() + INTERVAL '7 days')`,
[user.id, refreshToken, deviceInfo, ipAddress]
);
return {
manaToken,
refreshToken,
user: {
id: user.id,
email: user.email,
credits: user.credits,
},
};
}
// 2. App-specific token request
async function getAppToken(manaToken: string, appId: string) {
// Verify mana token
const payload = jwt.verify(manaToken, publicKey);
// Check app access
const hasAccess = await checkAppAccess(payload.sub, appId);
if (!hasAccess) throw new Error('No access to app');
// Generate app-specific token
const appToken = jwt.sign(
{
sub: payload.sub,
iss: 'manacore-auth',
aud: appId,
app_id: appId,
role: 'authenticated',
},
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
return appToken;
}
// 3. Token refresh
async function refreshTokens(refreshToken: string) {
// Validate refresh token
const session = await db.query(
'SELECT * FROM sessions WHERE refresh_token = $1 AND expires_at > NOW()',
[refreshToken]
);
if (!session.rows.length) {
throw new Error('Invalid refresh token');
}
// Check for reuse (security)
if (session.rows[0].last_used_at &&
Date.now() - session.rows[0].last_used_at.getTime() < 5000) {
// Token reuse detected - revoke all sessions
await revokeAllUserSessions(session.rows[0].user_id);
throw new Error('Token reuse detected');
}
// Generate new tokens
const user = await getUser(session.rows[0].user_id);
const newManaToken = generateManaToken(user);
const newRefreshToken = generateRefreshToken();
// Update session (token rotation)
await db.query(
`UPDATE sessions
SET refresh_token = $1, last_used_at = NOW()
WHERE id = $2`,
[newRefreshToken, session.rows[0].id]
);
return {
manaToken: newManaToken,
refreshToken: newRefreshToken,
};
}
7.4 Shared Auth Service (@manacore/shared-auth)
// packages/shared-auth/src/auth-service.ts
export interface AuthServiceConfig {
authUrl: string; // Mana Core auth API
appId: string; // App identifier
storage: AuthStorage; // Platform-specific storage
}
export class AuthService {
private config: AuthServiceConfig;
private manaToken: string | null = null;
private appToken: string | null = null;
private refreshToken: string | null = null;
constructor(config: AuthServiceConfig) {
this.config = config;
this.loadTokens();
}
// Login
async login(email: string, password: string) {
const response = await fetch(`${this.config.authUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
await this.setTokens(data.manaToken, data.refreshToken);
await this.getAppToken();
return data.user;
}
// Get app-specific token
async getAppToken() {
if (!this.manaToken) throw new Error('Not authenticated');
const response = await fetch(`${this.config.authUrl}/auth/app-token`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.manaToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ appId: this.config.appId }),
});
const { appToken } = await response.json();
this.appToken = appToken;
await this.config.storage.setItem('appToken', appToken);
return appToken;
}
// Refresh tokens
async refresh() {
if (!this.refreshToken) throw new Error('No refresh token');
const response = await fetch(`${this.config.authUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: this.refreshToken }),
});
const data = await response.json();
await this.setTokens(data.manaToken, data.refreshToken);
await this.getAppToken();
return data;
}
// Auto-refresh middleware
async getValidAppToken() {
// Check if app token expired
if (this.appToken && this.isTokenExpired(this.appToken)) {
// Try refreshing
await this.refresh();
}
return this.appToken;
}
private async setTokens(manaToken: string, refreshToken: string) {
this.manaToken = manaToken;
this.refreshToken = refreshToken;
await this.config.storage.setItem('manaToken', manaToken);
await this.config.storage.setItem('refreshToken', refreshToken);
}
private isTokenExpired(token: string): boolean {
const decoded = jwt.decode(token) as any;
return decoded.exp * 1000 < Date.now();
}
private async loadTokens() {
this.manaToken = await this.config.storage.getItem('manaToken');
this.appToken = await this.config.storage.getItem('appToken');
this.refreshToken = await this.config.storage.getItem('refreshToken');
}
}
// Platform-specific storage implementations
export interface AuthStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
// Expo mobile
export class ExpoSecureStorage implements AuthStorage {
async getItem(key: string) {
return await SecureStore.getItemAsync(key);
}
async setItem(key: string, value: string) {
await SecureStore.setItemAsync(key, value);
}
async removeItem(key: string) {
await SecureStore.deleteItemAsync(key);
}
}
// Web (httpOnly cookies)
export class BrowserStorage implements AuthStorage {
// Cookies managed server-side
async getItem(key: string) {
// Read from memory or fetch from /auth/session
return null;
}
async setItem(key: string, value: string) {
// Cookies set by server
}
async removeItem(key: string) {
// Call logout endpoint
}
}
7.5 Usage in Apps
Expo Mobile App
// apps/memoro/mobile/App.tsx
import { createAuthService } from '@manacore/shared-auth';
import { ExpoSecureStorage } from '@manacore/shared-auth/storage';
const authService = createAuthService({
authUrl: process.env.EXPO_PUBLIC_MIDDLEWARE_API_URL,
appId: 'memoro',
storage: new ExpoSecureStorage(),
});
// Login screen
const handleLogin = async (email: string, password: string) => {
try {
const user = await authService.login(email, password);
console.log('Logged in:', user);
} catch (error) {
console.error('Login failed:', error);
}
};
// API calls with auto-refresh
const fetchMemos = async () => {
const token = await authService.getValidAppToken();
const response = await fetch(`${API_URL}/memos`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
return response.json();
};
SvelteKit Web App
// apps/memoro/web/src/hooks.server.ts
import { createAuthService } from '@manacore/shared-auth';
import { SvelteKitStorage } from '@manacore/shared-auth/storage';
export async function handle({ event, resolve }) {
const authService = createAuthService({
authUrl: import.meta.env.VITE_AUTH_API_URL,
appId: 'memoro',
storage: new SvelteKitStorage(event.cookies),
});
// Make auth service available in routes
event.locals.auth = authService;
// Get user if authenticated
try {
event.locals.user = await authService.getCurrentUser();
} catch (error) {
event.locals.user = null;
}
return resolve(event);
}
NestJS Backend
// apps/memoro/backend/src/auth/auth.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import { readFileSync } from 'fs';
@Injectable()
export class JwtAuthGuard implements CanActivate {
private publicKey = readFileSync('public.key');
async canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) return false;
try {
// Verify JWT
const payload = jwt.verify(token, this.publicKey, {
algorithms: ['RS256'],
issuer: 'manacore-auth',
audience: 'memoro',
});
// Set user context
request.user = payload;
// Set database context for RLS
await this.dataSource.query(
'SET LOCAL app.current_user_id = $1',
[payload.sub]
);
return true;
} catch (error) {
return false;
}
}
}
7.6 Cross-App SSO
Users authenticated in one app can seamlessly access others:
// User clicks "Open in Memoro" from Maerchenzauber
// 1. Maerchenzauber has manaToken
const manaToken = await authService.getManaToken();
// 2. Deep link with token
const memoroUrl = `memoro://auth?token=${encodeURIComponent(manaToken)}`;
Linking.openURL(memoroUrl);
// 3. Memoro receives token
export default function App() {
useEffect(() => {
Linking.addEventListener('url', async (event) => {
const url = new URL(event.url);
if (url.pathname === '/auth') {
const token = url.searchParams.get('token');
await authService.setManaToken(token);
await authService.getAppToken(); // Get Memoro-specific token
// User now authenticated in Memoro
}
});
}, []);
}
7.7 Multi-App Patterns Summary
Benefits of Centralized Auth:
- ✅ Single source of truth for users
- ✅ Unified credit system
- ✅ Seamless cross-app SSO
- ✅ Consistent security policies
- ✅ Easier compliance & auditing
- ✅ Shared auth logic via @manacore/shared-auth
Implementation Checklist:
- Set up Mana Core auth service (NestJS)
- Generate RS256 key pair
- Create @manacore/shared-auth package
- Implement login/refresh endpoints
- Build app-token generation endpoint
- Create session management system
- Integrate with PostgreSQL RLS
- Add device tracking
- Implement 2FA
- Build admin dashboard for user management
- Set up monitoring and alerts
8. Technology Recommendation Matrix
8.1 Final Recommendations
| Component | Recommended Technology | Alternative | Rationale |
|---|---|---|---|
| Auth Library | Better Auth | Auth.js | Modern, comprehensive, TypeScript-first, no vendor lock-in |
| Database | PostgreSQL 16+ | - | Industry standard, RLS support, ACID compliance |
| ORM | Drizzle | Prisma | Better performance, Better Auth integration, type-safe |
| JWT Algorithm | RS256 | - | Asymmetric keys for microservices |
| Token Storage (Web) | httpOnly cookies | - | XSS protection |
| Token Storage (Mobile) | Expo SecureStore | - | Encrypted storage |
| Payment Gateway | Stripe | - | Best-in-class, comprehensive features |
| Session Management | Database + Redis | DB-only | Redis for blacklist, DB for sessions |
| Credit Ledger | PostgreSQL | - | ACID transactions essential |
8.2 Pros & Cons Summary
Better Auth
Pros:
- ✅ FREE and open-source
- ✅ Comprehensive features built-in
- ✅ TypeScript-first with auto-generation
- ✅ Framework-agnostic
- ✅ Perfect for monorepos
- ✅ Active development (YC-backed)
Cons:
- ⚠️ New (2024) - less proven
- ⚠️ Smaller community
- ⚠️ Documentation still growing
PostgreSQL + RLS
Pros:
- ✅ Battle-tested and reliable
- ✅ RLS provides defense in depth
- ✅ ACID transactions
- ✅ Excellent performance
- ✅ Rich ecosystem
Cons:
- ⚠️ Requires expertise to configure securely
- ⚠️ RLS policies can be complex
Stripe
Pros:
- ✅ Comprehensive payment methods
- ✅ Excellent documentation
- ✅ Reliable webhooks
- ✅ Global reach (47+ countries)
- ✅ Strong fraud prevention
Cons:
- ⚠️ 2.9% + $0.30 per transaction
- ⚠️ Pricing can add up at scale
9. Implementation Roadmap
Phase 1: Foundation (Weeks 1-2)
- Set up Better Auth with PostgreSQL
- Generate RS256 key pair
- Create basic auth API (login, register, refresh)
- Implement JWT validation middleware
- Set up user table with Better Auth schema
Phase 2: Multi-App Integration (Weeks 3-4)
- Create @manacore/shared-auth package
- Implement app-token generation
- Set up session management
- Add device tracking
- Configure RLS policies for each app
Phase 3: Credit System (Weeks 5-6)
- Create credit ledger schema
- Implement double-entry bookkeeping
- Add idempotency handling
- Build credit purchase API
- Create credit usage API
Phase 4: Payment Integration (Weeks 7-8)
- Set up Stripe account
- Implement payment intent creation
- Build webhook handlers
- Add payment method management
- Create credit packages
Phase 5: Advanced Features (Weeks 9-12)
- Add 2FA with Better Auth
- Implement multi-session management
- Build organization/multi-tenancy support
- Add OAuth providers
- Create admin dashboard
Phase 6: Production Readiness (Weeks 13-14)
- Security audit
- Performance testing
- Set up monitoring and alerts
- Write comprehensive tests
- Documentation
- Deploy to production
10. Security Checklist
Authentication
- Use Better Auth or battle-tested solution
- Implement 2FA for admin accounts
- Use SCRAM-SHA-256 for database auth
- Rate limit authentication endpoints
- Monitor failed login attempts
- Implement account lockout after failed attempts
JWT Security
- Use RS256 algorithm
- Set access token expiration to 15-30 minutes
- Implement refresh token rotation
- Validate all JWT claims (iss, aud, exp, iat)
- Store tokens in httpOnly cookies (web) or secure storage (mobile)
- Transmit only over HTTPS
- Implement token blacklist for revocation
Database Security
- Enable PostgreSQL RLS on all multi-tenant tables
- Use prepared statements (prevent SQL injection)
- Apply principle of least privilege to database roles
- Enable SSL/TLS for database connections
- Regular database backups with encryption
- Audit logging enabled
- Regular security updates
Payment Security
- Verify Stripe webhook signatures
- Use idempotency keys for all transactions
- Never trust client-side amounts
- Store sensitive data encrypted
- PCI compliance measures
- Fraud detection monitoring
- Regular reconciliation
General Security
- All endpoints behind HTTPS
- Input validation and sanitization
- Output encoding to prevent XSS
- CSRF protection
- Security headers (CSP, HSTS, etc.)
- Regular dependency updates
- Security penetration testing
- Incident response plan
11. Monitoring & Observability
Key Metrics to Track
Authentication Metrics
- Login success/failure rates
- Token refresh rates
- Session duration distribution
- Failed authentication attempts by IP
- 2FA adoption rate
- Active sessions per user
Payment Metrics
- Credit purchase volume
- Payment success/failure rates
- Refund rates
- Average transaction value
- Revenue by credit package
- Payment method distribution
Credit System Metrics
- Credit balance distribution
- Credit consumption rates by app
- Low balance alerts
- Expired credits
- Transaction volume
- Balance consistency checks
Security Metrics
- Suspicious login attempts
- Token reuse detection
- Failed authorization attempts
- RLS policy violations (shouldn't happen if configured correctly)
- Rate limit hits
Alerting Rules
// Example alert configurations
const alerts = [
{
name: 'High Failed Login Rate',
condition: 'failed_logins > 100 per 5 minutes',
severity: 'high',
action: 'notify_security_team',
},
{
name: 'Payment Webhook Failure',
condition: 'webhook_failures > 5 per 10 minutes',
severity: 'critical',
action: 'page_on_call',
},
{
name: 'Balance Mismatch Detected',
condition: 'balance_mismatch_count > 0',
severity: 'critical',
action: 'notify_finance_team',
},
{
name: 'Token Reuse Detected',
condition: 'token_reuse_count > 0',
severity: 'critical',
action: 'revoke_sessions_and_alert',
},
];
12. Additional Resources
Documentation
Libraries & Tools
Community
Conclusion
This comprehensive research provides a solid foundation for implementing a secure, scalable, and user-friendly central authentication system for the Mana Universe monorepo.
Key Takeaways:
-
Better Auth emerges as the best choice for modern TypeScript monorepos, offering comprehensive features, excellent developer experience, and zero vendor lock-in.
-
PostgreSQL with RLS provides robust multi-tenancy with defense-in-depth security, essential for a multi-app ecosystem.
-
Double-entry ledger pattern with idempotency ensures financial accuracy and auditability for the credit system.
-
JWT with RS256 and proper token management (short expiration, rotation) provides secure authentication across multiple apps.
-
Stripe integration offers reliable payment processing with comprehensive features and global reach.
-
Centralized auth service with app-specific tokens enables seamless SSO across the Mana ecosystem while maintaining app isolation.
The recommended architecture balances security, performance, developer experience, and cost-effectiveness, positioning the Mana Universe for scalable growth.
End of Report
For questions or clarifications, consult the Queen agent for aggregation with other research streams.