Sequence diagrams for every authentication flow in Private Landing. Each diagram maps directly to source code in packages/core/.
Note: The diagrams below show the default SQL-backed session path. Sessions can optionally be stored in Valkey/Redis cache instead, replacing SQL round-trips with cache GET/SET operations. See ADR-003 for details.
Rate limiting: Fixed-window middleware gates public auth routes before any business logic runs. Requests exceeding the threshold receive
429 Too Many Requestswith aRetry-Afterheader. When no cache is configured the rate limiter degrades to a no-op pass-through. See ADR-006.
Rendering: GitHub renders Mermaid natively. For local preview, use the Mermaid Live Editor or a VS Code extension.
User creates a new account. The password is hashed with PBKDF2-SHA384 before storage.
sequenceDiagram
participant U as User Agent
participant H as Hono Worker
participant RL as Rate Limiter
participant AS as AccountService
participant PS as PasswordService
participant DB as Turso DB
U->>H: POST /auth/register {email, password}
H->>RL: group limiter (rl:auth 20/300s) then route limiter (rl:register 5/300s) — IP-keyed
Note over RL: Fixed-window INCR+EXPIRE per client IP.<br/>Both checks run in sequence — either can reject.
alt Rate limit exceeded
RL-->>U: 429 {error: "Too many requests", Retry-After: 300}
end
H->>AS: createAccount(input, env)
AS->>AS: registrationSchema.safeParseAsync(input)
Note over AS: Zod validates email format<br/>and password length (NIST SP 800-63B)
alt Validation fails
AS-->>H: throw ValidationError
H-->>U: 400 {error, code: "VALIDATION_ERROR"}
end
AS->>PS: hashPassword(password)
Note over PS: 1. Generate 16-byte random salt<br/>2. PBKDF2 deriveBits (100k iterations, SHA-384)<br/>3. SHA-384 integrity digest<br/>4. Base64-encode salt, hash, digest
PS-->>AS: "$pbkdf2-sha384$v1$100000$salt$hash$digest"
AS->>DB: INSERT INTO account (email, password_data) VALUES (?, ?)
Note over AS,DB: Parameterized query — never string concatenation
alt Duplicate email
DB-->>AS: UNIQUE constraint error
AS-->>H: Generic "Registration failed" error
Note over H: Never reveal whether email exists
end
DB-->>AS: ResultSet
AS-->>H: Success
H-->>U: 201 {success: true} or redirect
Source: account-service.ts:125-147 | password-service.ts:203-249
Full authentication flow from credential verification through token issuance.
sequenceDiagram
participant U as User Agent
participant H as Hono Worker
participant RL as Rate Limiter
participant AS as AccountService
participant PS as PasswordService
participant SS as SessionService
participant TS as TokenService
participant DB as Turso DB
U->>H: POST /auth/login {email, password}
H->>RL: group limiter (rl:auth 20/300s) then route limiter (rl:login 5/300s) — IP-keyed
Note over RL: Throttles brute-force and credential-stuffing.<br/>429 is returned before any DB lookup occurs.
alt Rate limit exceeded
RL-->>U: 429 {error: "Too many requests", Retry-After: 300}
end
H->>AS: authenticate(input, env)
AS->>AS: loginSchema.safeParseAsync(input)
AS->>DB: SELECT password_data, id FROM account WHERE email = ?
alt User not found (0 rows)
AS->>PS: rejectPasswordWithConstantTime(password)
Note over PS: Runs full PBKDF2 against dummy hash<br/>to equalize response time.<br/>Prevents timing-based user enumeration.
PS-->>AS: false (always)
AS-->>H: {authenticated: false, error: "Invalid email or password"}
end
AS->>PS: verifyPassword(password, storedPasswordData)
Note over PS: 1. parsePasswordString() — extract salt, iterations<br/>2. PBKDF2 deriveBits with stored parameters<br/>3. timingSafeEqual() via crypto.subtle.verify()
PS-->>AS: true / false
alt Password incorrect
AS-->>H: {authenticated: false, error: "Invalid email or password"}
Note over H: Same error message as "user not found"
H-->>U: 401 {error: "Authentication failed"}
end
AS-->>H: {authenticated: true, userId}
H->>SS: createSession(userId, ctx)
SS->>DB: DELETE expired sessions
SS->>DB: Enforce max 3 sessions (CTE + ROW_NUMBER)
SS->>SS: nanoid() — 21 chars, ~121 bits entropy
SS->>DB: INSERT INTO session (id, user_id, user_agent, ip_address, expires_at, created_at)
SS-->>H: sessionId
H->>TS: generateTokens(ctx, userId, sessionId)
Note over TS: Access: {uid, sid, typ:"access", exp:+15min}<br/>Refresh: {uid, sid, typ:"refresh", exp:+7d}<br/>Signed with separate secrets (HS256)
TS->>U: Set-Cookie: access_token (HttpOnly, Secure, SameSite=Strict)
TS->>U: Set-Cookie: refresh_token (HttpOnly, Secure, SameSite=Strict)
H-->>U: 200 {success: true} or redirect
Source: account-service.ts:149-213 | session-service.ts:221-268 | token-service.ts:67-114 | app.ts:185-236
Accessing a protected endpoint with a valid access token.
sequenceDiagram
participant U as User Agent
participant MW as requireAuth Middleware
participant JWT as hono/jwt verify()
participant SS as SessionService
participant DB as Turso DB
participant RH as Route Handler
U->>MW: GET /account/me (Cookie: access_token=...)
MW->>MW: getCookie(ctx, "access_token")
MW->>JWT: verify(token, JWT_ACCESS_SECRET, AlgorithmTypes.HS256)
Note over JWT: Explicit HS256 prevents<br/>algorithm confusion attacks
JWT-->>MW: payload {uid, sid, typ:"access", exp}
MW->>MW: Check payload.typ === "access"
MW->>SS: getSession(ctx)
SS->>DB: UPDATE session SET expires_at = now + 7d WHERE id = ? AND expires_at > now
Note over SS,DB: Sliding expiration — extends on each use
SS->>DB: SELECT * FROM session WHERE id = ?
SS-->>MW: session {id, userId, ...}
MW->>MW: Verify session.id === payload.sid
MW->>RH: next()
RH-->>U: 200 {userId: 1}
Source: require-auth.ts:67-124 | require-auth.ts:163-191 (verifyToken) | session-service.ts:270-296
When the access token expires, the middleware transparently refreshes it using the refresh token.
sequenceDiagram
participant U as User Agent
participant MW as requireAuth Middleware
participant JWT as hono/jwt verify()
participant SS as SessionService
participant TS as TokenService
participant DB as Turso DB
U->>MW: GET /account/me [Cookie: access_token + refresh_token]
MW->>JWT: verify(accessToken, JWT_ACCESS_SECRET, HS256)
JWT-->>MW: Throws (expired)
Note over MW: Access token invalid — enter refresh flow
MW->>MW: getCookie(ctx, "refresh_token")
alt No refresh token
MW-->>U: 401 {code: "TOKEN_EXPIRED"}
end
MW->>JWT: verify(refreshToken, JWT_REFRESH_SECRET, HS256)
Note over JWT: Separate secret from access token<br/>prevents cross-type forgery
JWT-->>MW: payload {uid, sid, typ:"refresh", exp}
MW->>MW: Check payload.typ === "refresh"
MW->>SS: getSession(ctx)
SS->>DB: Extend + SELECT session
SS-->>MW: session or null
alt Session revoked or expired
MW-->>U: 403 {code: "SESSION_REVOKED"}
end
MW->>MW: Verify session.id === payload.sid
MW->>TS: refreshAccessToken(ctx, refreshPayload)
Note over TS: New payload: {uid, sid, typ:"access", exp:+15min}<br/>Signed with JWT_ACCESS_SECRET
TS->>U: Set-Cookie: access_token (new token)
MW->>JWT: verify(newAccessToken, JWT_ACCESS_SECRET, HS256)
JWT-->>MW: payload (validated)
MW->>MW: next()
MW-->>U: 200 (original request succeeds)
Source: require-auth.ts:85-103 | token-service.ts:116-143
Ends the server-side session and clears both auth cookies.
sequenceDiagram
participant U as User Agent
participant H as Hono Worker
participant MW as requireAuth Middleware
participant SS as SessionService
participant DB as Turso DB
U->>H: POST /auth/logout [Cookie: access_token + refresh_token]
H->>MW: requireAuth middleware validates token
MW-->>H: Authenticated (sets jwtPayload in context)
H->>SS: endSession(ctx)
SS->>SS: Extract payload.sid from context
SS->>DB: UPDATE session SET expires_at = datetime('now') WHERE id = ?
Note over SS,DB: Session expires immediately<br/>but row kept for audit trail
SS->>U: Set-Cookie: access_token="" (delete)
SS->>U: Set-Cookie: refresh_token="" (delete)
H-->>U: 200 {success: true} or redirect
Source: session-service.ts:298-314 | app.ts:129-153
User changes their password. Requires re-verification of the current password even though the user is authenticated. All sessions are revoked afterward, forcing re-authentication on every device.
sequenceDiagram
participant U as User Agent
participant H as Hono Worker
participant MW as requireAuth Middleware
participant AS as AccountService
participant PS as PasswordService
participant SS as SessionService
participant DB as Turso DB
U->>H: POST /account/password {currentPassword, newPassword}
H->>MW: requireAuth (verify existing session)
MW-->>H: Authenticated (userId from JWT payload)
H->>AS: changePassword(input, userId, env)
AS->>AS: passwordChangeSchema.safeParseAsync(input)
Note over AS: Zod validates both passwords (8–64 chars,<br/>NFKC normalization) and rejects<br/>newPassword === currentPassword
alt Validation fails
AS-->>H: throw ValidationError
H-->>U: 400 {error, code: "VALIDATION_ERROR"}
end
AS->>DB: SELECT password_data FROM account WHERE id = ?
alt User not found (0 rows)
AS->>PS: rejectPasswordWithConstantTime(currentPassword)
Note over PS: Runs full PBKDF2 against dummy hash<br/>to equalize response time
AS-->>H: throw ValidationError("Password change failed")
end
AS->>PS: verifyPassword(currentPassword, stored)
Note over PS: PBKDF2 + timingSafeEqual via<br/>crypto.subtle.verify()
alt Current password incorrect
AS-->>H: throw ValidationError("Password change failed")
Note over H: Same error as "user not found"
end
AS->>PS: hashPassword(newPassword)
Note over PS: Full PBKDF2 hash with fresh salt
AS->>DB: UPDATE account SET password_data = ? WHERE id = ?
AS-->>H: Success
H->>SS: endAllSessionsForUser(userId, ctx)
SS->>DB: UPDATE session SET expires_at = datetime('now')<br/>WHERE user_id = ? AND expires_at > datetime('now')
Note over SS,DB: All sessions expired atomically —<br/>including the current one
SS->>U: Set-Cookie: access_token="" (delete)
SS->>U: Set-Cookie: refresh_token="" (delete)
H-->>U: 200 {success: true} or redirect
Source: account-service.ts:215-268 | session-service.ts:316-331 | app.ts:239-280 | ADR-004