Skip to main content

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:

  1. Initial setup (first user creation)
  2. Login (existing user)
  3. Protected route access
  4. Logout
  5. 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