Authentication Workflows
Code examples and implementation patterns for authentication in EnvCat.
Overview
This guide provides practical code examples for common authentication workflows. All examples are production-ready and follow security best practices.
Workflows covered:
- Initial setup (first user creation)
- Login (existing user)
- Protected route access
- Logout
- Session validation (SPA pattern)
Prerequisites:
- Services running (
docker compose up -d) - Basic understanding of HTTP requests
- Familiarity with JavaScript/TypeScript or Python
Workflow 1: Initial Setup (First User)
Flow Diagram
User visits web UI
↓
No users exist → Redirect to /setup
↓
User fills form (username, password, confirm)
↓
POST /api/v1/auth/setup
↓
User created → Session created → Redirect to /bundles
Implementation
Frontend (React/Next.js)
// app/setup/page.tsx
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function SetupPage() {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
// Check if setup is required
useEffect(() => {
async function checkSetup() {
try {
const res = await fetch('/api/v1/auth/setup-required')
const data = await res.json()
if (!data.setup_required) {
// Users already exist, redirect to login
router.push('/login')
} else {
setLoading(false)
}
} catch (err) {
setError('Failed to check setup status')
setLoading(false)
}
}
checkSetup()
}, [router])
// Handle form submission
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
// Client-side validation
if (!username.trim()) {
setError('Username is required')
return
}
if (password.length < 10) {
setError('Password must be at least 10 characters')
return
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
setSubmitting(true)
try {
const res = await fetch('/api/v1/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Important: includes cookies
body: JSON.stringify({ username, password })
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Setup failed')
}
// Success: user created and logged in
// Redirect to bundles (via login page)
router.push('/login?setup=success')
} catch (err: any) {
setError(err.message || 'Setup failed')
} finally {
setSubmitting(false)
}
}
if (loading) {
return <div>Checking setup status...</div>
}
return (
<div className="container">
<h1>Create Admin Account</h1>
{error && <div className="alert error">{error}</div>}
<form onSubmit={handleSubmit}>
<label htmlFor="username">Username:</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={submitting}
required
autoFocus
/>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={submitting}
required
/>
<small>Minimum 10 characters</small>
<label htmlFor="confirm-password">Confirm Password:</label>
<input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={submitting}
required
/>
<button type="submit" disabled={submitting}>
{submitting ? 'Creating Account...' : 'Create Account'}
</button>
</form>
</div>
)
}
Backend (Python/Flask)
# routes/auth.py
from flask import Blueprint, request, jsonify
from flask_login import login_user
from db import get_db
from services.user_service import UserService
bp = Blueprint('auth', __name__)
@bp.route('/api/v1/auth/setup-required', methods=['GET'])
def setup_required():
"""Check if initial setup is required."""
db = next(get_db())
user_service = UserService(db)
return jsonify({
'setup_required': user_service.count_users() == 0
})
@bp.route('/api/v1/auth/setup', methods=['POST'])
def setup():
"""Create initial admin user."""
db = next(get_db())
user_service = UserService(db)
# Verify setup is allowed
if user_service.count_users() > 0:
return jsonify({'error': 'setup already complete'}), 403
# Parse request
data = request.get_json()
username = data.get('username', '').strip()
password = data.get('password', '')
try:
# Create user (validates username/password)
user = user_service.create_user(username, password)
# Automatically log in user
login_user(user, remember=False)
return jsonify({
'ok': True,
'user': {
'id': user.id,
'username': user.username,
'created_at': user.created_at.isoformat()
}
}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
return jsonify({'error': 'internal server error'}), 500
cURL
# 1. Check if setup is required
curl http://localhost:8888/api/v1/auth/setup-required
# Response: {"setup_required": true}
# 2. Create first user
curl -X POST http://localhost:8888/api/v1/auth/setup \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{
"username": "admin",
"password": "my-secure-password-123"
}'
# Response: {"ok": true, "user": {"id": "...", "username": "admin"}}
# Session cookie saved to cookies.txt
Workflow 2: Login (Existing User)
Flow Diagram
User visits /login (or redirected from protected route)
↓
User enters credentials (+ optional "remember me")
↓
POST /api/v1/auth/login
↓
Session created in Redis → Cookie set
↓
Redirect to /bundles (or return URL)
Implementation
Frontend (React/Next.js)
// app/login/page.tsx
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (!username.trim() || !password) {
setError('Username and password are required')
return
}
setSubmitting(true)
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password, remember })
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Login failed')
}
// Success: redirect to return URL or /bundles
const returnUrl = searchParams.get('return') || '/bundles'
router.push(returnUrl)
} catch (err: any) {
setError(err.message || 'Login failed')
} finally {
setSubmitting(false)
}
}
return (
<div className="container">
<h1>Login</h1>
{error && <div className="alert error">{error}</div>}
<form onSubmit={handleSubmit}>
<label htmlFor="username">Username:</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={submitting}
required
autoFocus
/>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={submitting}
required
/>
<label>
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
disabled={submitting}
/>
Remember me (30 days)
</label>
<button type="submit" disabled={submitting}>
{submitting ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
)
}
Backend (Python/Flask)
# routes/auth.py
@bp.route('/api/v1/auth/login', methods=['POST'])
@limiter.limit("5 per 15 minutes") # Rate limiting
def login():
"""Authenticate user and create session."""
db = next(get_db())
user_service = UserService(db)
data = request.get_json()
username = data.get('username', '').strip()
password = data.get('password', '')
remember = data.get('remember', False)
try:
# Authenticate (checks password, account lockout)
user = user_service.authenticate(username, password)
if user:
# Create session (24h or 30d based on remember)
login_user(user, remember=remember)
return jsonify({
'ok': True,
'user': {
'id': user.id,
'username': user.username
}
})
else:
return jsonify({'error': 'invalid credentials'}), 401
except Exception as e:
# Account locked or disabled
return jsonify({'error': str(e)}), 403
cURL
# Login with credentials
curl -X POST http://localhost:8888/api/v1/auth/login \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{
"username": "admin",
"password": "my-secure-password-123",
"remember": true
}'
# Response: {"ok": true, "user": {"id": "...", "username": "admin"}}
# Session cookie saved to cookies.txt
# Use session cookie for authenticated requests
curl http://localhost:8888/api/v1/bundles \
-b cookies.txt
# Response: [{"id": "...", "name": "dev/example", ...}]
Python (requests)
import requests
# Create session to persist cookies
session = requests.Session()
# Login
response = session.post(
'http://localhost:8888/api/v1/auth/login',
json={
'username': 'admin',
'password': 'my-secure-password-123',
'remember': True
}
)
if response.status_code == 200:
data = response.json()
print(f"Logged in as: {data['user']['username']}")
# Session cookie is now stored in session object
# Subsequent requests automatically include cookie
bundles = session.get('http://localhost:8888/api/v1/bundles')
print(f"Bundles: {bundles.json()}")
Workflow 3: Protected Route Access
Flow Diagram
User requests /bundles
↓
Middleware checks session (GET /api/v1/auth/session)
↓
Session valid → Render page
Session invalid → Redirect to /login?return=/bundles
Implementation
Next.js Middleware
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
// Check session validity
const response = await fetch('http://localhost:8080/api/v1/auth/session', {
headers: {
Cookie: request.headers.get('cookie') || ''
}
})
const data = await response.json()
if (!data.authenticated) {
// Not authenticated - redirect to login with return URL
const url = new URL('/login', request.url)
url.searchParams.set('return', request.nextUrl.pathname)
return NextResponse.redirect(url)
}
// Authenticated - allow request
return NextResponse.next()
}
// Apply middleware to protected routes
export const config = {
matcher: [
'/bundles/:path*', // All bundle routes
'/settings/:path*', // Settings pages
// /approve/:id remains public (CLI approval flow)
]
}
Flask Decorator
# routes/bundles.py
from flask import Blueprint, jsonify
from flask_login import login_required, current_user
bp = Blueprint('bundles', __name__)
@bp.route('/api/v1/bundles', methods=['GET'])
@login_required # Requires authentication
def list_bundles():
"""List all bundles for current user."""
# current_user is automatically available (Flask-Login)
bundles = Bundle.query.all()
return jsonify([{
'id': b.id,
'name': b.name,
'description': b.description
} for b in bundles])
React Component with Auth Check
// components/ProtectedRoute.tsx
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
interface User {
id: string
username: string
}
export function useAuth() {
const router = useRouter()
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function checkAuth() {
try {
const res = await fetch('/api/v1/auth/session', {
credentials: 'include'
})
const data = await res.json()
if (data.authenticated) {
setUser(data.user)
} else {
// Not authenticated - redirect to login
router.push('/login?return=' + window.location.pathname)
}
} catch (err) {
console.error('Auth check failed:', err)
router.push('/login')
} finally {
setLoading(false)
}
}
checkAuth()
}, [router])
return { user, loading }
}
// Usage in component
export function BundlesPage() {
const { user, loading } = useAuth()
if (loading) {
return <div>Loading...</div>
}
if (!user) {
return null // Will redirect to login
}
return (
<div>
<h1>Welcome, {user.username}!</h1>
{/* Bundle management UI */}
</div>
)
}
Workflow 4: Logout
Flow Diagram
User clicks logout button
↓
POST /api/v1/auth/logout
↓
Session deleted from Redis
↓
Cookie cleared → Redirect to /login
Implementation
Frontend (React/Next.js)
// components/Header.tsx
import { useAuth } from './useAuth'
export function Header() {
const { user } = useAuth()
async function handleLogout() {
try {
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include'
})
// Redirect to login page
window.location.href = '/login'
} catch (err) {
console.error('Logout failed:', err)
}
}
if (!user) {
return null
}
return (
<header>
<span>Logged in as: {user.username}</span>
<button onClick={handleLogout}>Logout</button>
</header>
)
}
Backend (Python/Flask)
# routes/auth.py
from flask_login import logout_user, login_required
@bp.route('/api/v1/auth/logout', methods=['POST'])
@login_required
def logout():
"""Destroy session and logout user."""
logout_user() # Deletes session from Redis
return jsonify({'ok': True})
cURL
# Logout (delete session)
curl -X POST http://localhost:8888/api/v1/auth/logout \
-b cookies.txt \
-c cookies.txt
# Response: {"ok": true}
# Verify session is invalid
curl http://localhost:8888/api/v1/bundles \
-b cookies.txt
# Response: 401 Unauthorized (redirected to login)
Workflow 5: Session Validation (SPA Pattern)
Flow Diagram
Page loads
↓
GET /api/v1/auth/session
↓
Response: { authenticated: true, user: {...} }
↓
Render authenticated UI (show username, logout button)
Implementation
React Hook
// hooks/useAuth.ts
import { useEffect, useState } from 'react'
interface User {
id: string
username: string
}
interface AuthState {
user: User | null
loading: boolean
authenticated: boolean
}
export function useAuth(): AuthState {
const [state, setState] = useState<AuthState>({
user: null,
loading: true,
authenticated: false
})
useEffect(() => {
async function checkSession() {
try {
const res = await fetch('/api/v1/auth/session', {
credentials: 'include'
})
const data = await res.json()
setState({
user: data.user || null,
loading: false,
authenticated: data.authenticated
})
} catch (err) {
console.error('Session check failed:', err)
setState({
user: null,
loading: false,
authenticated: false
})
}
}
checkSession()
}, [])
return state
}
Usage in Layout
// app/layout.tsx
'use client'
import { useAuth } from '@/hooks/useAuth'
export default function RootLayout({
children
}: {
children: React.ReactNode
}) {
const { user, loading, authenticated } = useAuth()
return (
<html>
<body>
<header>
<h1>EnvCat</h1>
{loading && <span>Loading...</span>}
{!loading && authenticated && (
<div>
<span>Logged in as: {user?.username}</span>
<button onClick={logout}>Logout</button>
</div>
)}
{!loading && !authenticated && (
<a href="/login">Login</a>
)}
</header>
<main>{children}</main>
</body>
</html>
)
}
async function logout() {
await fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' })
window.location.href = '/login'
}
Backend (Flask)
# routes/auth.py
from flask_login import current_user
@bp.route('/api/v1/auth/session', methods=['GET'])
def get_session():
"""Check authentication status."""
if current_user.is_authenticated:
return jsonify({
'authenticated': True,
'user': {
'id': current_user.id,
'username': current_user.username
}
})
else:
return jsonify({'authenticated': False})
Advanced Patterns
Automatic Session Refresh
// Keep session alive with periodic requests
import { useEffect } from 'react'
export function useSessionRefresh(intervalMs: number = 600000) { // 10 minutes
useEffect(() => {
const interval = setInterval(async () => {
// Ping session endpoint to refresh TTL
await fetch('/api/v1/auth/session', { credentials: 'include' })
}, intervalMs)
return () => clearInterval(interval)
}, [intervalMs])
}
// Usage in app
function App() {
useSessionRefresh() // Refresh session every 10 minutes
return <YourApp />
}
Retry on 401 (Auto-Redirect)
// Fetch wrapper with automatic redirect on 401
export async function authFetch(url: string, options: RequestInit = {}) {
const response = await fetch(url, {
...options,
credentials: 'include'
})
if (response.status === 401) {
// Session expired - redirect to login
window.location.href = '/login?return=' + window.location.pathname
throw new Error('Session expired')
}
return response
}
// Usage
const bundles = await authFetch('/api/v1/bundles').then(r => r.json())
Remember Me Toggle
// Toggle "remember me" on login page
function LoginForm() {
const [remember, setRemember] = useState(false)
return (
<form onSubmit={handleLogin}>
{/* ... username/password fields ... */}
<label>
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>
Remember me for 30 days
</label>
<button type="submit">Login</button>
</form>
)
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
await fetch('/api/v1/auth/login', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ username, password, remember })
})
}
}
Security Best Practices
Always Use credentials: 'include'
// ✅ Correct: includes session cookie
fetch('/api/v1/bundles', {
credentials: 'include'
})
// ❌ Wrong: cookie not sent
fetch('/api/v1/bundles') // credentials defaults to 'same-origin'
Never Log Passwords
// ❌ Wrong: logs password
console.log('Login attempt:', { username, password })
// ✅ Correct: omit password
console.log('Login attempt:', { username })
Clear Sensitive State on Logout
function logout() {
// Clear session cookie (server-side)
await fetch('/api/v1/auth/logout', { method: 'POST' })
// Clear local state
setUser(null)
setToken(null) // If using any local storage
// Redirect
window.location.href = '/login'
}
Handle Rate Limiting Gracefully
async function login(username: string, password: string) {
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
})
if (res.status === 429) {
// Rate limited
const retryAfter = res.headers.get('Retry-After')
throw new Error(`Too many attempts. Retry in ${retryAfter} seconds.`)
}
if (!res.ok) {
throw new Error('Login failed')
}
return res.json()
} catch (err) {
console.error('Login error:', err)
throw err
}
}
See Also
- API Reference: Authentication - Complete API documentation
- Getting Started: Authentication Setup - Setup guide
- Security: Authentication Model - Security details