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