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
28 KiB
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):
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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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)
-- 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
// 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
// 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:
-
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
-
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
-
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
-
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 - Argon2id parameters, algorithm priority
- OWASP Session Management Cheat Sheet - Cookie settings, timeout values
- OWASP Forgot Password Cheat Sheet - Reset token best practices
- Password4j GitHub - v1.8.4, API examples
- jwt-scala Documentation - v11.0.3, ZIO JSON integration
- java-totp GitHub - v1.7.1, TOTP API
Secondary (MEDIUM confidence)
- WinterCMS Backend User Model - Permission patterns
- WinterCMS UserRole Model - Role structure
- Stytch JWT vs Sessions Guide - Hybrid approach rationale
- Authgear Account Lockout - Progressive lockout patterns
Tertiary (LOW confidence)
- zio-email GitHub - 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)