- Overview
- Quick Start: Supabase Auth (Default)
- Quick Start: Local PostgreSQL Auth
- Architecture
- Database Schema
- Configuration Reference
- OAuth Setup
- Security Best Practices
- API Reference
- Comparison: Supabase vs Local PostgreSQL
- Migration Guide
- Troubleshooting
SynthStack implements a flexible authentication system that supports multiple providers through a unified abstraction layer. This allows you to choose the auth provider that best fits your deployment requirements without changing application code.
| Provider | Status | Use Case |
|---|---|---|
| Supabase | Default, Production-ready | Managed service, fastest setup |
| Local PostgreSQL | Production-ready | Self-hosted, no external dependencies |
| Directus | Planned | Enterprise deployments |
- Provider Abstraction - Switch auth providers via database config (no code changes)
- Enterprise Security - Argon2id password hashing (65536 memory cost)
- JWT Sessions - Access tokens (1h) + refresh tokens (7d) with rotation
- OAuth Support - Google, GitHub, Discord, Microsoft social login (both Supabase and Local)
- Auto-Detection - Automatically uses local auth when Supabase is not configured
- Account Protection - Lockout after failed attempts, email verification
- Session Management - Token families detect reuse attacks
- Audit Trail - Login history, IP tracking, device identification
- Auth Provider Wizard - Pick Supabase vs Local
- Supabase Auth Setup - Recommended path (OAuth)
- Local Auth Setup - Fully self-hosted (OAuth supported)
Recommended for: Most users, fastest setup, managed service
- Sign up at https://supabase.com
- Create a new project
- Wait for database provisioning (~2 minutes)
- Navigate to Settings → API
- Copy the following:
- Project URL →
SUPABASE_URL - anon/public key →
SUPABASE_ANON_KEY - service_role key →
SUPABASE_SERVICE_ROLE_KEY(secret!)
- Project URL →
Add to your root .env:
SUPABASE_URL=https://xxxxx.supabase.co
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
VITE_SUPABASE_URL=https://xxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Supabase is the default provider. To explicitly set it:
UPDATE auth_provider_config
SET
active_provider = 'supabase',
supabase_enabled = true;- In Supabase: Authentication → URL Configuration
- Site URL:
https://yourdomain.com - Redirect URLs: include
https://yourdomain.com/**andhttp://localhost:3050/**
- Site URL:
- In Supabase: Authentication → Providers
- Enable Google/GitHub/etc and paste client ID/secret
Guides:
Done! Your app now uses Supabase authentication.
Recommended for: Self-hosted deployments, full data control, no external dependencies
Create a secure 256-bit secret for signing tokens:
openssl rand -base64 32Copy the output (e.g., x3H7k9mP2vR5wQ8sL1nC4bF6tY0jU9iA3gD5hK7mN2q=)
Add to your root .env:
# Required for local auth
JWT_SECRET=x3H7k9mP2vR5wQ8sL1nC4bF6tY0jU9iA3gD5hK7mN2q=
DATABASE_URL=postgresql://user:password@localhost:5432/synthstack
# Optional: Remove Supabase vars if not using
# SUPABASE_URL=...
# SUPABASE_ANON_KEY=...
# SUPABASE_SERVICE_ROLE_KEY=...Local auth tables are created by:
services/directus/migrations/070_local_auth.sqlservices/directus/migrations/071_local_auth_schema_fix.sql
If you need to apply it manually:
docker compose exec -T postgres psql -U "${DB_USER:-synthstack}" -d "${DB_DATABASE:-synthstack}" < services/directus/migrations/070_local_auth.sql
docker compose exec -T postgres psql -U "${DB_USER:-synthstack}" -d "${DB_DATABASE:-synthstack}" < services/directus/migrations/071_local_auth_schema_fix.sqlConnect to your PostgreSQL database and run:
UPDATE auth_provider_config
SET
active_provider = 'local',
local_enabled = true,
supabase_enabled = false;Local auth can send verification/reset emails when your email provider is configured. If no email provider is configured, tokens are logged in the API output for development.
OAuth is supported for local auth via the service layer. Configure OAuth providers normally.
Next: Local Auth Setup (Wizard)
Done! Your app now uses local PostgreSQL authentication.
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (Vue) │
│ apps/web/src/services/auth.ts │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ API Gateway (Fastify) │
│ packages/api-gateway/src/routes/auth.ts │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Auth Service Layer │
│ packages/api-gateway/src/services/auth/index.ts │
│ │
│ 1. Reads auth_provider_config table │
│ 2. Detects active provider │
│ 3. Routes requests to appropriate provider │
└────────────────────────────┬────────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Supabase │ │ Local PG │ │ Directus │
│ Provider │ │ Provider │ │ Provider │
└────────────┘ └────────────┘ └────────────┘
│ │ │
▼ ▼ ▼
Supabase PostgreSQL Directus
Auth API Database Users Table
The system determines which provider to use at runtime by reading the auth_provider_config table:
SELECT active_provider FROM auth_provider_config LIMIT 1;
-- Returns: 'supabase' | 'local' | 'directus'This means you can switch providers without redeploying by updating a single database row.
- User submits login form →
POST /api/v1/auth/signin - API Gateway calls
AuthService.signIn(email, password) - AuthService reads
auth_provider_config.active_provider - Provider router delegates to appropriate provider:
- Supabase: Calls Supabase Auth API
- Local: Queries
local_auth_credentials, verifies Argon2id hash
- Provider returns
AuthSession(user + tokens) - API Gateway returns session to frontend
- Frontend stores access token, uses for authenticated requests
Stores password hashes and authentication metadata.
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key |
user_id |
UUID | Foreign key → app_users.id |
password_hash |
VARCHAR(255) | Argon2id hashed password |
password_changed_at |
TIMESTAMPTZ | Last password change |
reset_token |
VARCHAR(255) | Password reset token (hashed) |
reset_token_expires_at |
TIMESTAMPTZ | Reset token expiration |
email_verified |
BOOLEAN | Email verification status |
email_verification_token |
VARCHAR(255) | Verification token (hashed) |
email_verification_sent_at |
TIMESTAMPTZ | When verification email sent |
email_verified_at |
TIMESTAMPTZ | When email was verified |
failed_login_attempts |
INTEGER | Counter for account lockout |
locked_until |
TIMESTAMPTZ | Account locked until timestamp |
last_login_at |
TIMESTAMPTZ | Last successful login |
last_login_ip |
INET | IP address of last login |
mfa_enabled |
BOOLEAN | Multi-factor auth enabled (future) |
mfa_secret |
VARCHAR(255) | TOTP secret (future) |
Indexes:
idx_local_auth_useronuser_ididx_local_auth_reset_tokenonreset_tokenidx_local_auth_verification_tokenonemail_verification_tokenidx_local_auth_lockedonlocked_until
Tracks active sessions with refresh token management.
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key |
user_id |
UUID | Foreign key → app_users.id |
token_hash |
VARCHAR(255) | SHA-256 hash of refresh token |
token_family |
UUID | Token family for rotation detection |
ip_address |
INET | IP address of session creation |
user_agent |
TEXT | Browser/device user agent |
device_name |
VARCHAR(255) | Friendly device name |
location |
VARCHAR(255) | Geo-location from IP |
issued_at |
TIMESTAMPTZ | When session was created |
expires_at |
TIMESTAMPTZ | Session expiration |
last_used_at |
TIMESTAMPTZ | Last time token was used |
is_active |
BOOLEAN | Session active status |
revoked_at |
TIMESTAMPTZ | When session was revoked |
revoked_reason |
VARCHAR(100) | Revocation reason |
Revocation Reasons:
logout- User logged outpassword_change- Password changedadmin- Admin revoked sessiontoken_rotation- Refresh token rotatedsuspicious- Suspicious activity detected
Indexes:
idx_sessions_useron(user_id, is_active)idx_sessions_tokenontoken_hashidx_sessions_expiresonexpires_atidx_sessions_familyontoken_family
Social login connections for OAuth providers.
| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key |
user_id |
UUID | Foreign key → app_users.id |
provider |
VARCHAR(50) | google, github, discord, microsoft |
provider_user_id |
VARCHAR(255) | Provider's user ID |
provider_email |
VARCHAR(255) | Email from provider |
provider_username |
VARCHAR(255) | Username from provider |
access_token_encrypted |
TEXT | Encrypted OAuth access token |
refresh_token_encrypted |
TEXT | Encrypted OAuth refresh token |
token_expires_at |
TIMESTAMPTZ | OAuth token expiration |
profile_data |
JSONB | Cached profile data |
avatar_url |
VARCHAR(500) | Profile picture URL |
scopes |
TEXT[] | Granted OAuth scopes |
connected_at |
TIMESTAMPTZ | When connection was created |
last_used_at |
TIMESTAMPTZ | Last login via this provider |
disconnected_at |
TIMESTAMPTZ | When connection was removed |
Unique Constraint: (provider, provider_user_id)
Global authentication configuration (singleton table).
| Column | Type | Default | Description |
|---|---|---|---|
active_provider |
VARCHAR(50) | supabase |
Active provider: supabase, local, directus |
supabase_enabled |
BOOLEAN | true |
Enable Supabase auth |
local_enabled |
BOOLEAN | false |
Enable local PostgreSQL auth |
directus_enabled |
BOOLEAN | false |
Enable Directus auth |
| Password Policy | |||
local_password_min_length |
INTEGER | 8 |
Minimum password length |
local_password_require_uppercase |
BOOLEAN | true |
Require uppercase letter |
local_password_require_lowercase |
BOOLEAN | true |
Require lowercase letter |
local_password_require_number |
BOOLEAN | true |
Require number |
local_password_require_special |
BOOLEAN | false |
Require special character |
| Session Settings | |||
local_session_duration_hours |
INTEGER | 168 |
Session duration (7 days) |
local_max_sessions_per_user |
INTEGER | 5 |
Max concurrent sessions |
local_require_email_verification |
BOOLEAN | true |
Require email verification |
| Security | |||
local_max_failed_login_attempts |
INTEGER | 5 |
Max failed logins before lockout |
local_lockout_duration_minutes |
INTEGER | 30 |
Lockout duration |
| JWT | |||
jwt_access_token_expires_minutes |
INTEGER | 60 |
Access token lifetime (1 hour) |
jwt_refresh_token_expires_days |
INTEGER | 7 |
Refresh token lifetime (7 days) |
| OAuth | |||
oauth_google_enabled |
BOOLEAN | false |
Enable Google OAuth |
oauth_google_client_id |
VARCHAR(255) | NULL |
Google OAuth client ID |
oauth_github_enabled |
BOOLEAN | false |
Enable GitHub OAuth |
oauth_github_client_id |
VARCHAR(255) | NULL |
GitHub OAuth client ID |
oauth_discord_enabled |
BOOLEAN | false |
Enable Discord OAuth |
oauth_discord_client_id |
VARCHAR(255) | NULL |
Discord OAuth client ID |
oauth_microsoft_enabled |
BOOLEAN | false |
Enable Microsoft OAuth |
oauth_microsoft_client_id |
VARCHAR(255) | NULL |
Microsoft OAuth client ID |
Note: OAuth client secrets should be stored in environment variables, not the database.
SELECT * FROM auth_provider_config;-- Switch to Local PostgreSQL
UPDATE auth_provider_config
SET active_provider = 'local', local_enabled = true, supabase_enabled = false;
-- Switch to Supabase
UPDATE auth_provider_config
SET active_provider = 'supabase', supabase_enabled = true, local_enabled = false;
-- Enable both (user chooses during signup)
UPDATE auth_provider_config
SET supabase_enabled = true, local_enabled = true, active_provider = 'supabase';UPDATE auth_provider_config
SET
local_password_min_length = 12,
local_password_require_uppercase = true,
local_password_require_lowercase = true,
local_password_require_number = true,
local_password_require_special = true;-- Increase session lifetime to 30 days
UPDATE auth_provider_config
SET
local_session_duration_hours = 720,
jwt_refresh_token_expires_days = 30;
-- Shorter sessions for high-security environments
UPDATE auth_provider_config
SET
local_session_duration_hours = 24,
jwt_refresh_token_expires_days = 1,
jwt_access_token_expires_minutes = 15;-- Stricter lockout policy
UPDATE auth_provider_config
SET
local_max_failed_login_attempts = 3,
local_lockout_duration_minutes = 60;
-- More lenient policy
UPDATE auth_provider_config
SET
local_max_failed_login_attempts = 10,
local_lockout_duration_minutes = 15;OAuth is supported when using the Supabase auth provider (active_provider = 'supabase').
High-level steps:
- Create an OAuth app in Google/GitHub/Discord/etc.
- Set the OAuth app callback/redirect URL to your Supabase callback:
https://<YOUR_SUPABASE_PROJECT_REF>.supabase.co/auth/v1/callback
- In Supabase: Authentication → Providers
- Enable the provider and paste the client ID/secret
- In Supabase: Authentication → URL Configuration
- Set Site URL and Redirect URLs (include your production domain +
http://localhost:3050/**)
- Set Site URL and Redirect URLs (include your production domain +
Guides:
- Supabase social login docs: https://supabase.com/docs/guides/auth/social-login
- SynthStack guide: OAuth Setup (Supabase)
OAuth is supported for both Supabase and Local auth providers via the service layer.
Never use a weak secret. Generate a cryptographically secure 256-bit key:
# macOS/Linux
openssl rand -base64 32
# Alternative
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"SynthStack implements refresh token rotation to detect token theft:
- Each refresh token belongs to a token family (UUID)
- When refreshing, the old token is invalidated and a new one is issued
- If an old (already-rotated) token is reused, all tokens in that family are revoked
- This detects token theft and prevents attackers from maintaining access
Recommendations by environment:
| Environment | Access Token | Refresh Token | Notes |
|---|---|---|---|
| Development | 60 min | 30 days | Convenience over security |
| Production | 15-60 min | 7 days | Balance security and UX |
| High Security | 5-15 min | 1-3 days | Banking, healthcare |
- Max failed attempts: 3-10 (5 recommended)
- Lockout duration: 15-60 minutes (30 recommended)
- Consider: Exponential backoff for repeated lockouts
Always enable in production:
UPDATE auth_provider_config SET local_require_email_verification = true;Prevents:
- Fake account creation
- Email enumeration (with careful error messages)
- Spam/abuse
Never send JWT tokens over HTTP. Always use HTTPS in production:
# Force HTTPS redirect
server {
listen 80;
server_name api.synthstack.app;
return 301 https://$host$request_uri;
}Local auth uses Argon2id with:
- Memory cost: 65536 KB (64 MB)
- Iterations: 3
- Parallelism: 4 threads
This configuration resists GPU/ASIC attacks. Do not reduce these values.
All authentication endpoints are prefixed with /api/v1/auth.
Register a new user account.
Request:
curl -X POST https://api.synthstack.app/api/v1/auth/signup \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "SecurePass123",
"displayName": "John Doe"
}'Response (201):
{
"session": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "a1b2c3d4e5f6...",
"expiresAt": "2025-01-07T10:00:00Z",
"provider": "local"
},
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"displayName": "John Doe",
"emailVerified": false,
"createdAt": "2025-01-06T09:00:00Z"
}
}Sign in with email and password.
Request:
curl -X POST https://api.synthstack.app/api/v1/auth/signin \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "SecurePass123"
}'Response (200): Same as signup
Error (401):
{
"error": "Invalid credentials",
"code": "AUTH_INVALID_CREDENTIALS"
}Error (423 - Account Locked):
{
"error": "Account locked due to too many failed login attempts",
"code": "AUTH_ACCOUNT_LOCKED",
"lockedUntil": "2025-01-06T10:30:00Z"
}Sign out and revoke the current session.
Request:
curl -X POST https://api.synthstack.app/api/v1/auth/signout \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."Response (200):
{
"message": "Signed out successfully"
}Refresh access token using refresh token.
Request:
curl -X POST https://api.synthstack.app/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "a1b2c3d4e5f6..."
}'Response (200):
{
"session": {
"accessToken": "eyJhbGci...",
"refreshToken": "x9y8z7w6...",
"expiresAt": "2025-01-07T11:00:00Z"
}
}Note: Old refresh token is invalidated (token rotation).
Request password reset email.
Request:
curl -X POST https://api.synthstack.app/api/v1/auth/reset-password-request \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]"
}'Response (200):
{
"message": "If an account exists with this email, a reset link has been sent"
}Note: Always returns success to prevent email enumeration.
Complete password reset with token.
Request:
curl -X POST https://api.synthstack.app/api/v1/auth/reset-password \
-H "Content-Type: application/json" \
-d '{
"token": "reset-token-from-email",
"newPassword": "NewSecurePass456"
}'Response (200):
{
"message": "Password reset successful"
}Get authentication provider configuration.
Request:
curl https://api.synthstack.app/api/v1/auth/providersResponse (200):
{
"activeProvider": "local",
"availableProviders": ["local", "supabase"],
"oauthProviders": {
"google": { "enabled": true, "clientId": "123...apps.googleusercontent.com" },
"github": { "enabled": true, "clientId": "Ov23li..." },
"discord": { "enabled": false },
"microsoft": { "enabled": false }
},
"features": {
"emailVerificationRequired": true,
"mfaSupported": false
}
}Get OAuth authorization URL.
Request:
curl "https://api.synthstack.app/api/v1/auth/oauth/google?redirect_uri=https://synthstack.app/auth/callback"Response (200):
{
"authUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=...",
"state": "random-state-token"
}Frontend redirects user to authUrl.
Handle OAuth callback (called by OAuth provider redirect).
Request:
curl -X POST https://api.synthstack.app/api/v1/auth/oauth/callback \
-H "Content-Type: application/json" \
-d '{
"provider": "google",
"code": "4/0AY0e...",
"state": "random-state-token"
}'Response (200):
{
"session": { "accessToken": "...", "refreshToken": "..." },
"user": { "id": "...", "email": "[email protected]", ... }
}Get current authenticated user profile.
Request:
curl https://api.synthstack.app/api/v1/auth/me \
-H "Authorization: Bearer eyJhbGci..."Response (200):
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "[email protected]",
"displayName": "John Doe",
"avatarUrl": "https://...",
"emailVerified": true,
"authProvider": "local",
"createdAt": "2025-01-06T09:00:00Z"
}| Aspect | Supabase Auth | Local PostgreSQL Auth |
|---|---|---|
| Setup Complexity | ⭐⭐⭐⭐⭐ Easy (5 min) | ⭐⭐⭐ Moderate (15 min) |
| External Dependencies | Yes (Supabase service) | None |
| OAuth Providers | Built-in (Google, GitHub, Discord, Apple) | Not supported yet (coming soon) |
| Cost | $0-25/month (free: 50k users, 2GB DB) | $0 (included in server) |
| Backups (Auth Data) | Managed by Supabase (plan-dependent) | You manage Postgres backups |
| Data Sovereignty | Hosted by Supabase | Full control |
| Scalability | Auto-scaling | Manual (database scaling) |
| Email Templates | Built-in, customizable | Custom implementation |
| Admin Dashboard | Supabase UI | Custom/SQL queries |
| MFA Support | Built-in | Prepared (not implemented) |
| Audit Logs | Built-in | Custom implementation |
| Password Hashing | bcrypt | Argon2id (stronger) |
| Session Management | Supabase manages | Full control |
| Migration Effort | None (default) | Update config + env vars |
| Lock-in Risk | Vendor lock-in | No lock-in |
✅ Fast setup needed - Get auth working in 5 minutes ✅ Managed service preference - Don't want to manage auth infrastructure ✅ Built-in OAuth - Need social login without manual setup ✅ Admin UI - Want dashboard for user management ✅ Small-medium scale - Under 50k users (free tier) ✅ Email templates - Need pre-built auth emails
✅ Self-hosted requirement - Must run on your infrastructure ✅ No external dependencies - Can't rely on third-party services ✅ Full data control - Data sovereignty requirements ✅ Cost optimization - High user count (>50k users) ✅ Email/password is enough - OAuth for local auth is coming later ✅ No vendor lock-in - Want to own the entire stack
1. Export Users from Supabase
-- Run in Supabase SQL editor
SELECT
id, email, created_at, email_confirmed_at
FROM auth.users;Export to CSV.
2. Switch Auth Provider
-- In your SynthStack database
UPDATE auth_provider_config
SET active_provider = 'local', local_enabled = true, supabase_enabled = false;3. Import Users
-- Create users in app_users table
INSERT INTO app_users (id, email, auth_provider, email_verified, email_verified_at, date_created)
VALUES ('uuid-from-supabase', '[email protected]', 'local', true, '2025-01-01', '2025-01-01');
-- Users must reset passwords (no way to export Supabase password hashes)4. Send Password Reset Emails
Notify all users to reset their passwords via the forgot password flow.
1. Export Users
SELECT u.id, u.email, u.date_created, u.email_verified
FROM app_users u
WHERE u.auth_provider = 'local';2. Create Users in Supabase
Use Supabase Admin API:
const { createClient } = require('@supabase/supabase-js');
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
for (const user of users) {
await supabase.auth.admin.createUser({
email: user.email,
email_confirm: user.email_verified
});
}3. Switch Provider
UPDATE auth_provider_config
SET active_provider = 'supabase', supabase_enabled = true, local_enabled = false;4. Update User Records
UPDATE app_users
SET auth_provider = 'supabase'
WHERE auth_provider = 'local';Symptoms: 401 Unauthorized errors, "Invalid token" messages
Causes:
- JWT_SECRET mismatch between services
- Token expired
- Token format invalid
Solutions:
# 1. Verify JWT_SECRET is identical in all services
grep JWT_SECRET packages/*/\.env
# 2. Check token expiration
# Decode token at https://jwt.io
# Verify 'exp' claim is in the future
# 3. Verify token structure
# Should have format: Authorization: Bearer <token>Symptoms: 423 Account Locked error
Cause: Too many failed login attempts
Solutions:
-- Check lockout status
SELECT email, failed_login_attempts, locked_until
FROM local_auth_credentials
WHERE email = '[email protected]';
-- Manually unlock account
UPDATE local_auth_credentials
SET
failed_login_attempts = 0,
locked_until = NULL
WHERE email = '[email protected]';Prevention:
-- Increase lockout threshold
UPDATE auth_provider_config
SET local_max_failed_login_attempts = 10;Symptoms: Redirect to error page, "redirect_uri_mismatch" error
Causes:
- Callback URL not registered with OAuth provider
- CORS issues
- HTTP instead of HTTPS in production
Solutions:
1. Verify callback URL in provider settings:
Google: https://console.cloud.google.com → Credentials
GitHub: https://github.com/settings/developers
Expected URL: https://api.synthstack.app/api/v1/auth/oauth/callback
2. Check CORS configuration (packages/api-gateway/src/index.ts):
app.register(cors, {
origin: ['https://synthstack.app', 'http://localhost:5173'],
credentials: true
});3. Force HTTPS in production:
# Nginx config
add_header Strict-Transport-Security "max-age=31536000" always;Symptoms: "Invalid or expired reset token" error
Cause: Token expired (1 hour lifetime) or already used
Solutions:
-- Check token expiration
SELECT
email,
reset_token,
reset_token_expires_at,
reset_token_expires_at > NOW() as is_valid
FROM local_auth_credentials
WHERE reset_token = 'token-from-email';
-- Generate new token (users should request reset again)
-- Tokens are one-time use and auto-expire after 1 hourSymptoms: "Email not verified" errors, verification emails not sending
Causes:
- Email service not configured
- Verification disabled
- Token expired (24 hours)
Solutions:
1. Check email service configuration:
# Check Resend API key in root .env
grep RESEND_API_KEY .env2. Verify email verification is enabled:
SELECT local_require_email_verification
FROM auth_provider_config;
-- Should be TRUE in production
UPDATE auth_provider_config
SET local_require_email_verification = true;3. Manually verify a user:
UPDATE local_auth_credentials
SET
email_verified = true,
email_verified_at = NOW()
WHERE user_id = 'user-uuid';
UPDATE app_users
SET
email_verified = true,
email_verified_at = NOW()
WHERE id = 'user-uuid';Symptoms: Users logged out after short time
Cause: Short session duration settings
Solutions:
-- Check current settings
SELECT
jwt_access_token_expires_minutes,
jwt_refresh_token_expires_days
FROM auth_provider_config;
-- Increase session duration
UPDATE auth_provider_config
SET
jwt_access_token_expires_minutes = 120, -- 2 hours
jwt_refresh_token_expires_days = 30; -- 30 daysRun this function periodically (via cron job) to purge old sessions:
SELECT cleanup_expired_sessions();
-- Returns: number of deleted sessionsDeletes:
- Sessions expired >7 days ago
- Revoked sessions >30 days old
Force logout a user (e.g., after password change or security incident):
SELECT revoke_all_user_sessions('user-uuid', 'password_change');
-- Returns: number of revoked sessionsRevocation reasons:
logout- User logged outpassword_change- Password changedadmin- Admin actionsuspicious- Security event
- Self-Hosting Guide - Deployment and configuration
- Feature Flags - Auth-related feature flags
- API Gateway README - API reference
- Email Service - Email configuration for auth emails
For issues, questions, or feature requests:
- GitHub Issues: https://github.com/your-repo/synthstack/issues
- Documentation: https://synthstack.app/docs
- Community Discord: https://discord.gg/synthstack