# 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)
