Files
Jakub Zych 10cdd3f638 docs(06): research backend authentication domain
Phase 6: Backend Authentication
- Standard stack: Password4j (Argon2id), jwt-scala, java-totp
- Architecture: JWT + DB hybrid sessions, progressive lockout, TOTP 2FA
- RBAC: WinterCMS-style plugin-registered permissions
- Security: OWASP-compliant password storage, session management, reset tokens
2026-02-05 14:29:39 +01:00

693 lines
28 KiB
Markdown

# Phase 6: Backend Authentication - Research
**Researched:** 2026-02-05
**Domain:** Backend admin authentication with password hashing, sessions, TOTP 2FA, and RBAC permissions
**Confidence:** HIGH
## Summary
This phase implements secure authentication for the SummerCMS admin backend. The research covers password hashing (Argon2id recommended, BCrypt as fallback), session management (database-backed for revocation support), TOTP 2FA, password reset flows, progressive lockout, and WinterCMS-style plugin-registered permissions with flat RBAC.
The recommended approach uses Password4j for Argon2id hashing (OWASP primary recommendation), JWT for stateless session tokens with database-backed revocation tracking, java-totp for TOTP 2FA with Google Authenticator compatibility, and a custom permission registry that aggregates plugin-registered permissions at boot time. Sessions are stored in the database to support the "view and revoke sessions" requirement, with JWT for efficient validation without database lookup on every request.
**Primary recommendation:** Use Argon2id via Password4j (OWASP #1 choice), database-backed sessions with JWT tokens for scalability, java-totp for 2FA, and a plugin-based permission registry matching WinterCMS patterns.
## Standard Stack
The established libraries/tools for this domain:
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Password4j | 1.8.4 | Password hashing | Supports Argon2id (OWASP #1), BCrypt, scrypt; JVM standard |
| jwt-scala | 11.0.3 | JWT tokens | Scala 3 native, ZIO JSON integration, actively maintained |
| java-totp | 1.7.1 | TOTP 2FA | QR code generation, Google Authenticator compatible |
| ZIO | 2.1.x | Effect system | Already in stack, Ref for state, Hub for events |
| Quill | 4.8.6 | Database access | Already in stack, compile-time SQL validation |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| jwt-zio-json | 11.0.3 | JWT + ZIO JSON | JSON codec for JWT claims |
| zxing | 3.5.x | QR codes | Transitive via java-totp for 2FA setup QR |
| JavaMail | 1.6.x | Email sending | Password reset emails (wrap in ZIO effects) |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Password4j | scala-bcrypt | scala-bcrypt lacks Scala 3 support and only does BCrypt |
| Password4j | jbcrypt | jbcrypt is BCrypt only, no Argon2id |
| jwt-scala | zio-jwt-validator | zio-jwt-validator only validates, doesn't encode |
| java-totp | kuro-otp | kuro-otp is Scala native but less maintained |
| Database sessions | Pure JWT | Pure JWT cannot be revoked without tracking |
**Installation (Mill build.mill):**
```scala
def mvnDeps = Seq(
// Existing deps...
// Password hashing
mvn"com.password4j:password4j:1.8.4",
// JWT
mvn"com.github.jwt-scala::jwt-zio-json:11.0.3",
// TOTP 2FA
mvn"dev.samstevens.totp:totp:1.7.1",
// Email (for password reset)
mvn"com.sun.mail:jakarta.mail:2.0.1"
)
```
## Architecture Patterns
### Recommended Project Structure
```
src/
├── auth/
│ ├── AdminUser.scala # Admin user model
│ ├── AdminSession.scala # Session model (database)
│ ├── Role.scala # Role model with permissions
│ ├── Permission.scala # Permission definition
│ ├── PasswordService.scala # Argon2id hashing
│ ├── SessionService.scala # JWT + DB session management
│ ├── TotpService.scala # 2FA setup/verification
│ ├── LoginService.scala # Login logic with lockout
│ ├── PasswordResetService.scala # Reset token handling
│ └── PermissionRegistry.scala # Plugin permission aggregation
├── auth/middleware/
│ ├── AuthMiddleware.scala # JWT validation middleware
│ └── PermissionMiddleware.scala # Permission checking
└── auth/routes/
├── LoginRoutes.scala # Login/logout endpoints
├── PasswordResetRoutes.scala # Reset flow endpoints
└── SessionRoutes.scala # Session management endpoints
```
### Pattern 1: Argon2id Password Hashing with Password4j
**What:** Hash and verify passwords using Argon2id (OWASP primary recommendation)
**When to use:** All password storage and verification
**Example:**
```scala
// Source: Password4j docs + OWASP recommendations
import com.password4j.Password
trait PasswordService:
def hash(password: String): Task[String]
def verify(password: String, hash: String): Task[Boolean]
object PasswordService:
val live: ZLayer[Any, Nothing, PasswordService] =
ZLayer.succeed {
new PasswordService:
// OWASP recommended: m=19456 (19 MiB), t=2, p=1
def hash(password: String): Task[String] =
ZIO.attemptBlocking {
Password.hash(password)
.addRandomSalt()
.withArgon2() // Uses Argon2id by default
.getResult
}
def verify(password: String, hash: String): Task[Boolean] =
ZIO.attemptBlocking {
Password.check(password, hash).withArgon2()
}
}
```
### Pattern 2: Hybrid Session Management (JWT + Database)
**What:** JWT for efficient validation, database for revocation and session listing
**When to use:** All authenticated requests
**Example:**
```scala
// Source: jwt-scala docs + session requirements
import pdi.jwt.{JwtZIOJson, JwtAlgorithm, JwtClaim}
import zio.json.*
case class SessionClaims(
userId: Long,
sessionId: String, // Links to DB session for revocation
role: String,
issuedAt: Instant,
expiresAt: Instant
) derives JsonCodec
case class AdminSession(
id: String,
userId: Long,
createdAt: Instant,
expiresAt: Instant,
ipAddress: String,
userAgent: String,
revoked: Boolean = false
)
trait SessionService:
def createSession(userId: Long, rememberMe: Boolean, ip: String, ua: String): Task[String]
def validateToken(token: String): Task[SessionClaims]
def revokeSession(sessionId: String): Task[Unit]
def listSessions(userId: Long): Task[List[AdminSession]]
def revokeAllSessions(userId: Long): Task[Unit]
object SessionService:
val live: ZLayer[SessionRepository & Config, Nothing, SessionService] =
ZLayer.fromFunction { (repo: SessionRepository, config: Config) =>
new SessionService:
private val algorithm = JwtAlgorithm.HS256
def createSession(userId: Long, rememberMe: Boolean, ip: String, ua: String): Task[String] =
for
sessionId <- ZIO.succeed(java.util.UUID.randomUUID().toString)
now <- Clock.instant
duration = if rememberMe then config.rememberMeDuration else config.sessionDuration
expiresAt = now.plus(duration)
session = AdminSession(sessionId, userId, now, expiresAt, ip, ua)
_ <- repo.create(session)
claims = SessionClaims(userId, sessionId, role, now, expiresAt)
token <- ZIO.attempt {
JwtZIOJson.encode(claims.toJson, config.jwtSecret, algorithm)
}
yield token
def validateToken(token: String): Task[SessionClaims] =
for
claimsJson <- ZIO.fromTry {
JwtZIOJson.decode(token, config.jwtSecret, Seq(algorithm))
}.mapError(e => AuthError.InvalidToken(e.getMessage))
claims <- ZIO.fromEither(claimsJson.content.fromJson[SessionClaims])
_ <- ZIO.fail(AuthError.TokenExpired).when(claims.expiresAt.isBefore(Instant.now))
session <- repo.findById(claims.sessionId)
_ <- ZIO.fail(AuthError.SessionRevoked).when(session.revoked)
yield claims
}
```
### Pattern 3: Progressive Lockout with Exponential Backoff
**What:** Track failed login attempts and enforce progressive delays
**When to use:** Login attempts
**Example:**
```scala
// Source: CONTEXT.md + security best practices
case class LoginAttempt(
email: String,
ipAddress: String,
attemptedAt: Instant,
successful: Boolean
)
trait LoginService:
def authenticate(email: String, password: String, totpCode: Option[String], ip: String): Task[AuthResult]
object LoginService:
// Lockout thresholds (Claude's discretion per CONTEXT.md)
private val lockoutThresholds = List(
(5, 10.minutes), // After 5 failures: 10 min lockout
(10, 20.minutes), // After 10 failures: 20 min lockout
(15, 60.minutes), // After 15 failures: 1 hour lockout
(20, 24.hours) // After 20 failures: 24 hour lockout
)
def calculateLockout(failedAttempts: Int): Option[Duration] =
lockoutThresholds.reverse.find(_._1 <= failedAttempts).map(_._2)
// Implementation checks:
// 1. Count recent failed attempts for email/IP
// 2. If locked out, return error with remaining time
// 3. Validate credentials
// 4. If 2FA enabled, validate TOTP code
// 5. Record attempt (success or failure)
// 6. On success, clear failed attempts for this email
```
### Pattern 4: TOTP 2FA with Recovery Codes
**What:** Optional TOTP 2FA with backup recovery codes
**When to use:** Admin users who enable 2FA
**Example:**
```scala
// Source: java-totp documentation
import dev.samstevens.totp.secret.DefaultSecretGenerator
import dev.samstevens.totp.code.{DefaultCodeGenerator, DefaultCodeVerifier}
import dev.samstevens.totp.time.SystemTimeProvider
import dev.samstevens.totp.qr.{QrData, ZxingPngQrGenerator}
trait TotpService:
def generateSecret: Task[String]
def generateQrCodeUri(secret: String, email: String): Task[String]
def verifyCode(secret: String, code: String): Task[Boolean]
def generateRecoveryCodes: Task[List[String]]
object TotpService:
val live: ZLayer[Any, Nothing, TotpService] =
ZLayer.succeed {
new TotpService:
private val secretGenerator = new DefaultSecretGenerator()
private val codeGenerator = new DefaultCodeGenerator()
private val codeVerifier = new DefaultCodeVerifier(codeGenerator, new SystemTimeProvider())
private val qrGenerator = new ZxingPngQrGenerator()
def generateSecret: Task[String] =
ZIO.attemptBlocking(secretGenerator.generate())
def generateQrCodeUri(secret: String, email: String): Task[String] =
ZIO.attemptBlocking {
val data = new QrData.Builder()
.label(email)
.secret(secret)
.issuer("SummerCMS Admin")
.algorithm(HashingAlgorithm.SHA1)
.digits(6)
.period(30)
.build()
val imageData = qrGenerator.generate(data)
Utils.getDataUriForImage(imageData, qrGenerator.getImageMimeType)
}
def verifyCode(secret: String, code: String): Task[Boolean] =
ZIO.attemptBlocking {
codeVerifier.isValidCode(secret, code)
}
def generateRecoveryCodes: Task[List[String]] =
ZIO.attemptBlocking {
val generator = new RecoveryCodeGenerator()
generator.generateCodes(8).asScala.toList // 8 recovery codes
}
}
```
### Pattern 5: Password Reset with Secure Tokens
**What:** Time-limited, single-use reset tokens with secure storage
**When to use:** Password reset flow
**Example:**
```scala
// Source: OWASP Forgot Password Cheat Sheet + CONTEXT.md
import java.security.SecureRandom
import java.security.MessageDigest
case class PasswordResetToken(
tokenHash: String, // SHA-256 hash of token (stored)
userId: Long,
createdAt: Instant,
expiresAt: Instant,
ipAddress: String,
used: Boolean = false
)
trait PasswordResetService:
def requestReset(email: String, ip: String): Task[Unit] // Always returns success (timing attack prevention)
def validateToken(token: String): Task[Long] // Returns userId if valid
def resetPassword(token: String, newPassword: String): Task[Unit]
object PasswordResetService:
// Token expiry: 30 minutes (Claude's discretion per CONTEXT.md)
// OWASP recommends 15-60 minutes; 30 is a good middle ground
private val tokenExpiry = 30.minutes
private val tokenLength = 64 // Characters (256 bits of entropy)
def generateToken: Task[String] =
ZIO.attemptBlocking {
val random = new SecureRandom()
val bytes = new Array[Byte](48) // 48 bytes = 64 base64 chars
random.nextBytes(bytes)
java.util.Base64.getUrlEncoder.withoutPadding.encodeToString(bytes)
}
def hashToken(token: String): String =
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(token.getBytes("UTF-8"))
hash.map("%02x".format(_)).mkString
// Reset email includes (per CONTEXT.md):
// - IP address of requester
// - Approximate location (optional, via IP geolocation)
// - Request timestamp
```
### Pattern 6: Plugin-Registered Permission System
**What:** Plugins register their own permissions, roles aggregate permissions
**When to use:** All permission checks in controllers and views
**Example:**
```scala
// Source: WinterCMS pattern from golem15-wintercms-starter
case class PermissionDef(
code: String, // e.g., "golem15.blog.access_posts"
label: String, // Human-readable label
tab: String, // Grouping in admin UI
order: Int = 0
)
trait PermissionRegistry:
def register(pluginId: PluginId, permissions: List[PermissionDef]): UIO[Unit]
def getAllPermissions: UIO[List[PermissionDef]]
def getPermissionsForPlugin(pluginId: PluginId): UIO[List[PermissionDef]]
object PermissionRegistry:
val live: ZLayer[Any, Nothing, PermissionRegistry] =
ZLayer.fromZIO {
Ref.make(Map.empty[PluginId, List[PermissionDef]]).map { registry =>
new PermissionRegistry:
def register(pluginId: PluginId, permissions: List[PermissionDef]): UIO[Unit] =
registry.update(_ + (pluginId -> permissions))
def getAllPermissions: UIO[List[PermissionDef]] =
registry.get.map(_.values.flatten.toList.sortBy(p => (p.tab, p.order)))
def getPermissionsForPlugin(pluginId: PluginId): UIO[List[PermissionDef]] =
registry.get.map(_.getOrElse(pluginId, Nil))
}
}
// Role model - flat, no inheritance (per CONTEXT.md)
case class Role(
id: Long,
code: String, // e.g., "super_admin", "editor"
name: String,
permissions: Set[String], // Permission codes granted
isSystem: Boolean = false
)
// Permission checking
trait PermissionChecker:
def hasPermission(userId: Long, permission: String): Task[Boolean]
def hasAnyPermission(userId: Long, permissions: List[String]): Task[Boolean]
// Wildcard matching: "golem15.blog.*" matches "golem15.blog.access_posts"
def matchesPermission(granted: String, required: String): Boolean =
if granted.endsWith(".*") then
required.startsWith(granted.dropRight(1))
else
granted == required
```
### Pattern 7: Auth Middleware for ZIO HTTP
**What:** Middleware that validates JWT and injects user context
**When to use:** All protected routes
**Example:**
```scala
// Source: ZIO HTTP middleware pattern
import zio.http.*
object AuthMiddleware:
def apply: HandlerAspect[SessionService, SessionClaims] =
HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request =>
for
token <- ZIO.fromOption(
request.header(Header.Authorization).collect {
case Header.Authorization.Bearer(token) => token
}
).orElseFail(Response.unauthorized("Missing authorization header"))
sessionService <- ZIO.service[SessionService]
claims <- sessionService.validateToken(token)
.mapError(_ => Response.unauthorized("Invalid or expired token"))
yield (request, claims)
})
object PermissionMiddleware:
def require(permission: String): HandlerAspect[PermissionChecker & SessionClaims, Unit] =
HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request =>
for
claims <- ZIO.service[SessionClaims]
checker <- ZIO.service[PermissionChecker]
allowed <- checker.hasPermission(claims.userId, permission)
_ <- ZIO.fail(Response.forbidden("Insufficient permissions")).unless(allowed)
yield (request, ())
})
```
### Anti-Patterns to Avoid
- **Storing plain-text passwords:** Always hash with Argon2id or BCrypt
- **Using MD5/SHA1 for passwords:** These are fast hashes, not password hashes
- **Predictable reset tokens:** Use cryptographically secure random generation
- **JWT without revocation:** Must track sessions in DB for revocation support
- **Timing attacks on login:** Always hash password even if user not found
- **Verbose error messages:** Never reveal whether email exists or which field is wrong
- **Session fixation:** Regenerate session ID after successful login
## Don't Hand-Roll
Problems that look simple but have existing solutions:
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Password hashing | Custom hash function | Password4j with Argon2id | Secure by default, tunable parameters, constant-time comparison |
| JWT encoding/decoding | Manual JSON + signing | jwt-scala | Proper algorithm handling, claim validation, timing-safe comparison |
| TOTP generation | Manual RFC 6238 | java-totp | Time drift handling, recovery codes, QR generation |
| Secure random tokens | Random.nextInt | SecureRandom | Cryptographically secure entropy source |
| Session ID generation | UUID.randomUUID alone | UUID + database + JWT | Needs revocation, listing, IP tracking |
| Permission matching | String equality | Wildcard pattern matching | Need `plugin.*` to match `plugin.action` |
**Key insight:** Authentication and authorization are security-critical. Hand-rolled solutions inevitably miss edge cases around timing attacks, secure comparison, entropy, and token lifecycle.
## Common Pitfalls
### Pitfall 1: Timing Attacks on Login
**What goes wrong:** Login reveals whether email exists based on response time
**Why it happens:** Only hashing password when user exists (fast failure for unknown email)
**How to avoid:** Always perform password hash comparison, even for non-existent users (compare against dummy hash)
**Warning signs:** Different response times for existing vs non-existing emails
### Pitfall 2: Insecure Password Reset Token Storage
**What goes wrong:** Attacker with database read access can use reset tokens
**Why it happens:** Storing tokens in plain text
**How to avoid:** Hash tokens with SHA-256 before storing, compare hashes
**Warning signs:** Reset tokens visible as plain strings in database
### Pitfall 3: Session Fixation
**What goes wrong:** Attacker sets session ID before victim logs in, hijacks after login
**Why it happens:** Not regenerating session after authentication state change
**How to avoid:** Create new session on login, invalidate any pre-auth session
**Warning signs:** Session ID same before and after login
### Pitfall 4: JWT Without Revocation
**What goes wrong:** Cannot log user out, cannot terminate sessions on password change
**Why it happens:** JWTs are self-contained and valid until expiry
**How to avoid:** Track session IDs in database, check revocation status on each request
**Warning signs:** "Logout" doesn't actually prevent access with old token
### Pitfall 5: Insufficient Password Reset Token Entropy
**What goes wrong:** Attacker brute-forces reset tokens
**Why it happens:** Using short or predictable tokens (sequential IDs, timestamps)
**How to avoid:** Use 256+ bits of entropy from SecureRandom, URL-safe Base64 encoding
**Warning signs:** Tokens under 32 characters, tokens containing timestamps/IDs
### Pitfall 6: Permission Check Bypass
**What goes wrong:** Some routes accessible without proper permission
**Why it happens:** Middleware applied inconsistently, forgetting to check in views
**How to avoid:** Centralize permission checking in middleware, fail-closed defaults
**Warning signs:** Protected actions accessible without role assignment
## Code Examples
Verified patterns from official sources:
### Database Schema (Flyway Migration)
```sql
-- Source: Phase requirements + WinterCMS patterns
-- V6__backend_auth.sql
CREATE TABLE admin_users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
role_id BIGINT REFERENCES roles(id),
totp_secret VARCHAR(64), -- Null if 2FA not enabled
totp_enabled BOOLEAN DEFAULT false,
recovery_codes TEXT[], -- Hashed recovery codes
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL,
permissions TEXT[] NOT NULL DEFAULT '{}',
is_system BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE admin_sessions (
id VARCHAR(64) PRIMARY KEY, -- UUID
user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
ip_address INET,
user_agent TEXT,
revoked BOOLEAN DEFAULT false,
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_admin_sessions_user_id ON admin_sessions(user_id);
CREATE INDEX idx_admin_sessions_expires_at ON admin_sessions(expires_at);
CREATE TABLE password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex
user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
ip_address INET,
used BOOLEAN DEFAULT false,
used_at TIMESTAMPTZ
);
CREATE TABLE login_attempts (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
ip_address INET NOT NULL,
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
successful BOOLEAN NOT NULL
);
CREATE INDEX idx_login_attempts_email ON login_attempts(email, attempted_at);
CREATE INDEX idx_login_attempts_ip ON login_attempts(ip_address, attempted_at);
-- Insert default Super Admin role
INSERT INTO roles (code, name, permissions, is_system)
VALUES ('super_admin', 'Super Admin', '{"*"}', true);
```
### Cookie Configuration
```scala
// Source: OWASP Session Management Cheat Sheet
import zio.http.*
object CookieConfig:
def sessionCookie(token: String, maxAge: Duration): Cookie =
Cookie.Response(
name = "__Host-SID", // __Host- prefix for additional security
content = token,
maxAge = Some(maxAge),
path = Some(Path.root / "admin"),
isSecure = true, // HTTPS only
isHttpOnly = true, // No JavaScript access
sameSite = Some(Cookie.SameSite.Strict) // No cross-site requests
)
```
### Login Endpoint
```scala
// Source: ZIO HTTP + CONTEXT.md requirements
import zio.http.*
import zio.json.*
case class LoginRequest(
email: String,
password: String,
totpCode: Option[String],
rememberMe: Boolean = false
) derives JsonCodec
case class LoginResponse(
token: String,
user: AdminUserSummary
) derives JsonCodec
val loginRoute = Routes(
Method.POST / "admin" / "auth" / "login" -> handler { (req: Request) =>
for
body <- req.body.asString
login <- ZIO.fromEither(body.fromJson[LoginRequest])
.mapError(_ => Response.badRequest("Invalid request body"))
ip = req.remoteAddress.map(_.toString).getOrElse("unknown")
service <- ZIO.service[LoginService]
result <- service.authenticate(login.email, login.password, login.totpCode, ip)
.mapError {
case AuthError.InvalidCredentials =>
// Generic message per CONTEXT.md
Response.unauthorized("Invalid email or password")
case AuthError.AccountLocked(until) =>
Response.status(Status.TooManyRequests)
.addHeader(Header.RetryAfter.Delta(until))
case AuthError.TotpRequired =>
Response.json("""{"requiresTotp": true}""").status(Status.Unauthorized)
}
sessionService <- ZIO.service[SessionService]
token <- sessionService.createSession(result.user.id, login.rememberMe, ip, req.header(Header.UserAgent).map(_.renderedValue).getOrElse(""))
response = LoginResponse(token, result.user.toSummary)
yield Response.json(response.toJson)
.addCookie(CookieConfig.sessionCookie(token,
if login.rememberMe then 30.days else 24.hours
))
}
)
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| BCrypt only | Argon2id primary | OWASP 2022+ | Better GPU resistance |
| MD5/SHA1 for passwords | Never use | Always | Not suitable for passwords |
| Session cookies only | JWT + DB hybrid | 2020+ | Scalability + revocation |
| 6-digit SMS OTP | TOTP app-based | 2019+ | SIM swap resistance |
| Role inheritance | Flat roles | Depends on needs | Simpler to reason about |
| Global session timeout | Per-session expiry | Current | "Remember me" flexibility |
**Deprecated/outdated:**
- BCrypt work factor 10: Now recommend 12-14 minimum (2026)
- Plain session cookies without SameSite: Always use SameSite=Strict/Lax
- JWT RS256 with 1024-bit keys: Use RS256 with 2048+ bits or HS256
## Open Questions
Things that couldn't be fully resolved:
1. **Email Sending Library**
- What we know: zio-email exists but unmaintained (last update ~2019), Emil is Cats-based not ZIO
- What's unclear: Best ZIO-native approach for email
- Recommendation: Wrap JavaMail in ZIO effects directly, simple and reliable
2. **IP Geolocation for Reset Emails**
- What we know: CONTEXT.md mentions including location in reset emails
- What's unclear: Which IP geolocation service to use
- Recommendation: Consider MaxMind GeoIP2 Lite (free) or skip location initially
3. **TOTP Time Drift**
- What we know: java-totp has configurable time period discrepancy
- What's unclear: What discrepancy to allow (default is 1 period = 30 seconds each side)
- Recommendation: Use default (1 period), allow 90 second window total
4. **Session Cleanup**
- What we know: Sessions expire but remain in database
- What's unclear: Cleanup frequency
- Recommendation: Scheduled job daily to delete expired sessions older than 7 days
## Sources
### Primary (HIGH confidence)
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - Argon2id parameters, algorithm priority
- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) - Cookie settings, timeout values
- [OWASP Forgot Password Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html) - Reset token best practices
- [Password4j GitHub](https://github.com/Password4j/password4j) - v1.8.4, API examples
- [jwt-scala Documentation](https://jwt-scala.github.io/jwt-scala/jwt-zio-json.html) - v11.0.3, ZIO JSON integration
- [java-totp GitHub](https://github.com/samdjstevens/java-totp) - v1.7.1, TOTP API
### Secondary (MEDIUM confidence)
- [WinterCMS Backend User Model](golem15-wintercms-starter/modules/backend/models/User.php) - Permission patterns
- [WinterCMS UserRole Model](golem15-wintercms-starter/modules/backend/models/UserRole.php) - Role structure
- [Stytch JWT vs Sessions Guide](https://stytch.com/blog/jwts-vs-sessions-which-is-right-for-you/) - Hybrid approach rationale
- [Authgear Account Lockout](https://docs.authgear.com/reference/rate-limits/account-lockout) - Progressive lockout patterns
### Tertiary (LOW confidence)
- [zio-email GitHub](https://github.com/funcit/zio-email) - Unmaintained, pattern reference only
- Various Medium articles on Argon2 vs BCrypt - Validated against OWASP
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH - All libraries verified via official docs, OWASP recommendations
- Architecture patterns: HIGH - Based on OWASP cheat sheets and established ZIO patterns
- Pitfalls: HIGH - OWASP security guidance, well-documented attack vectors
- Permission system: MEDIUM - Based on WinterCMS patterns, adapted for Scala
**Research date:** 2026-02-05
**Valid until:** 2026-03-05 (30 days - security libraries stable, update if vulnerabilities discovered)