Authentication and sessions
Before You Start
Start with an ordinary HTTP route that can validate a form, set a cookie, and redirect, plus a named live session that can run a mount aspect. The HTTP and Live routes must also be able to receive one shared authentication service; service layers are one way to provide that capability.
Separate HTTP Login From Live Authorization
Use ordinary HTTP for credential submission, cookie changes, and redirects. Use a session mount aspect to authorize the disconnected and connected LiveView lifecycles. This division keeps password handling out of socket events and lets the browser apply normal cookie and redirect semantics. Cookies and original HTTP headers belong at ordinary HTTP/document or session boundaries, not in a route mount aspect.
The documentation authentication lab demonstrates this complete flow:
GET /examples/authentication/labrenders a CSRF-protected login form.POST /examples/authentication/lab/sessionvalidates the CSRF token, bounded form body, and fixed lab credentials.A successful response sets an opaque
HttpOnly,SameSite=Laxsession cookie and redirects to the protected route.The protected route authenticates that cookie during disconnected mount.
Connected mount receives only a public session ID in signed claims and checks the server-side session record again.
Reset revokes only the current visitor's application session, disconnects every active tab using its ID, clears that visitor's login attempts, expires the cookie, and redirects to the login page.
The lab uses fixed credentials and stores no account changes. Its route is intentionally excluded from the public content index because it is a standalone HTTP and LiveView lab rather than a documentation page.
Keep Credentials In Ordinary HTTP
Render a normal form with Form.httpdef http[Msg](target: FormAction)(mods: Mod.Input[Msg]*): HtmlElement[Msg]Renders an ordinary HTTP form; action and method are owned by target. and
decode it in a ZIO HTTP handler with
HttpFormDecoder.urlEncodeddef urlEncoded[A](codec: FormCodec[A], maxBytes: Long, csrf: CsrfProtection): HttpFormDecoder[A]Builds a bounded CSRF-protected decoder for a typed definition or low-level codec..
The decoder applies a byte limit, validates the framework CSRF token, and then
projects the rooted definition. Login only continues for valid domain output, so
use urlEncodedValue:
private val loginDecoder =
HttpFormDecoder.urlEncodedValue(LoginForm.Definition, 4096L, security.csrf)The urlEncoded(LoginForm.Definition, ...) variant instead returns a submitted
LoginForm.Definition.Form even when its result contains validation errors;
use that form when an HTTP response will render submitted values and feedback
again. Both definition-backed variants preserve typed form semantics after the
same body and CSRF checks.
Return one generic response for incorrect email and password values. Distinguish protocol failures such as malformed encoding, an oversized body, or an unsupported content type through ordinary HTTP status codes, but do not reveal whether an account exists.
The lab limits each browser-bound visitor record to five failed attempts per minute. Attempt records and sessions both have configured capacity bounds. A production application should choose a policy suitable for its threat model and deployment topology rather than copying these demonstration values blindly.
Store Opaque, Bounded Sessions
Generate a cryptographically random cookie token and store only its hash as the server-side lookup key. Keep a separate non-secret public session ID for claims. The lab sessions:
expire after 30 minutes;
are capped at 1,024 records;
evict the oldest record when full; and
are revoked explicitly by reset.
The in-memory store is intentionally process-local and suitable only for this single-instance lab. A production service normally persists sessions in a bounded shared store when multiple application instances must accept the same cookie.
Bind Admission To Physical Connections
withAdmission(aspect)(connectionId) installs the named live session's one
connection-admission boundary. The projection extracts an application-owned ID
from the aspect's signed claims. During connected mount, Scalive registers the
physical connection under that ID before running the aspect's authoritative
connected callback. A later LiveConnections.disconnect(id) can therefore
promptly close every registered physical connection, including multiple tabs;
each reconnect must pass admission again.
Use plain withMountAspect when typed mount context is sufficient and the
application does not need connection lookup, prompt invalidation, or distributed
disconnect fanout by that claim-derived ID. Admission complements durable
application-session revocation; LiveConnections is not authentication truth.
See the canonical combined pipeline
for the generic and accumulated context shapes.
Authenticate Both Mount Phases
The protected named live session installs this executable
LiveSessionMountAspectclass LiveSessionMountAspect[R, -In, Claims, Ctx]Produces session context immediately before disconnected and connected LiveView mount.:
final case class ConnectedAuth(
currentUser: CurrentUser,
revalidate: IO[LiveConnectedTurnFailure, Unit])
object AuthMountAspect:
val authenticated: LiveSessionMountAspect[AuthService, Any, AuthClaims, ConnectedAuth] =
LiveSessionMountAspect.fromRequest(
request =>
request.request.cookie(AuthHttpRoutes.SessionCookieName) match
case None => ZIO.fail(AuthLabRoutes.login.location.seeOther)
case Some(cookie) =>
ZIO
.serviceWithZIO[AuthService](auth =>
auth
.authenticate(SessionCookieToken(cookie.content))
.someOrFail(AuthLabRoutes.login.location.seeOther)
.map(current =>
AuthClaims(current.publicSessionId) -> connectedAuth(auth, current)
)
),
(claims, _) =>
ZIO
.serviceWithZIO[AuthService](auth =>
auth
.resume(claims.publicSessionId)
.someOrFail(LiveMountFailure.redirect(AuthLabRoutes.login.location))
.map(connectedAuth(auth, _))
)
)
private def connectedAuth(auth: AuthService, current: CurrentSession): ConnectedAuth =
ConnectedAuth(
CurrentUser(current.user.email, current.user.name),
auth
.resume(current.publicSessionId).someOrFail(
LiveConnectedTurnFailure.redirect(AuthLabRoutes.login.location)
).unit
)
end AuthMountAspectView source (documentation/site/src/scalive/docs/auth/AuthLab.scala:19-57)
During disconnected mount, authenticate validates the opaque cookie and
returns minimal AuthClaims plus a separate ConnectedAuth. During connected
mount, resume loads the session record again from the public claim ID and
rebuilds ConnectedAuth. That capability contains immutable CurrentUser
construction data and an environment-free revalidation effect that captures the
same AuthService instance. Revocation or expiry between those phases therefore
prevents the socket from mounting. The public session ID remains in the signed
claim instead of becoming part of every route's context.
Install the aspect once as the named live session's admission boundary and inject the small context into only the LiveViews that use it:
val protectedRoutes = Live
.session("authenticated")
.withAdmission(AuthMountAspect.authenticated)(_.publicSessionId)(
profile.context((auth: ConnectedAuth, accounts: Accounts) =>
new ProfileLiveView(auth.currentUser, accounts)
),
status(StatusLiveView())
)ProfileLiveView projects immutable CurrentUser construction data from the
admitted capability while Accounts comes from the application environment.
The status view remains authentication-agnostic even though the same admission
protects it. Route-specific authorization and sensitive mutations must still
enforce their own domain rules.
Use a claimless LiveRouteMountAspectclass LiveRouteMountAspect[R, A, -In, Ctx]Derives fresh typed route context before every disconnected or connected route mount.
after admission when access depends on destination parameters. It is the mount
boundary for loading a workspace membership or hiding an inaccessible record;
it is not a substitute for authorization immediately before a sensitive domain
operation. A live patch keeps the current lifecycle mounted and does not rerun
the route aspect, so reauthorize any patched resource identity in handleParams
or navigate instead. See Derive fresh context for every route mount
for the complete timing and failure semantics.
Revalidate Connected Turns
Mount admission decides whether a lifecycle may start. To recheck a session
before later connected application work, declare
guardConnectedTurnsdef guardConnectedTurns[Result <: Unit](guard: Ctx => zio.IO[LiveConnectedTurnFailure, Result]): LiveSessionBuilder.Admitted[R, Ctx]Appends a policy check before each connected application turn.
after admission. The executable lab installs the guard and projects only the
immutable user into its view:
val protectedSession
: LiveRouteFragment[AuthService & LiveConnections[PublicSessionId]] { type Input = Any } =
Live
.session("documentation-authentication-lab")
.withAdmission(AuthMountAspect.authenticated)(_.publicSessionId)
.guardConnectedTurns((auth: ConnectedAuth) => auth.revalidate)(
AuthLabRoutes.profile.context((auth: ConnectedAuth) => ProfileLiveView(auth.currentUser))
)View source (documentation/site/src/scalive/docs/auth/AuthLab.scala:244-251)
Here ConnectedAuth.revalidate returns
IO[LiveConnectedTurnFailure, Unit]: ZIO.unit means Continue, while
ZIO.fail(LiveConnectedTurnFailure.redirect(...)) stops the turn and redirects
to login. Other controlled failures may Halt, perform a trusted-destination
unsafe Redirect, Reload, or Disconnect. Keep authoritative mutable
application session or account state behind the admission-produced context
rather than relying on claims captured at mount. See
LiveConnectedTurnFailureenum LiveConnectedTurnFailureA controlled outcome that stops a connected LiveView turn before hooks, handlers, or rendering.
for every outcome.
A guard runs when an application turn arrives, not while a physical connection
is idle. Continue to call LiveConnections.disconnect when revocation should
promptly close existing connections; the next admission then decides whether
they may reconnect. A turn guard narrows the revocation window, but it does not
replace domain authorization immediately before sensitive mutations. Recheck
the relevant record or capability where it is used to avoid time-of-check/time-of-use gaps.
Guard checks are on the hot path. Read authoritative mutable data, but bound the
cost and use caching only when its staleness is acceptable for the policy. A
failure reason supplied to reload(reason) or disconnect(reason) is
server-side diagnostic context, not a message sent to the browser.
Do not transfer the cookie token in claims. Signed claims are authenticated but not encrypted. Read Layouts, live sessions, and mount aspects for the phase boundary in detail.
Revoke Through A CSRF-Protected Reset
Logout changes server state and must not be a GET. The lab uses another
CSRF-protected ordinary form. Its handlers are extracted from the running
application:
private def login(request: Request): URIO[AuthService, Response] =
loginDecoder.respond(
request,
onValidation = _ => invalidLoginResponse,
onRejected = logMalformedLogin
) { credentials =>
ZIO.serviceWithZIO[AuthService](_.login(visitor(request), credentials)).flatMap {
case LoginDecision.Successful(result) =>
ZIO.succeed(
AuthLabRoutes.profile.location.seeOther
.addCookie(security.cookies.make(SessionCookieName, result.cookieToken.value))
)
case LoginDecision.Invalid => invalidLoginResponse
case LoginDecision.RateLimited => rateLimitedResponse
}
}
private def reset(
request: Request
): URIO[AuthService & LiveConnections[PublicSessionId], Response] =
resetDecoder.respond(request, _ => ZIO.succeed(Response.forbidden)) { _ =>
val cookieToken = request
.cookie(SessionCookieName)
.map(cookie => SessionCookieToken(cookie.content))
val revokeAndDisconnect = for
publicSessionId <- ZIO.serviceWithZIO[AuthService](
_.reset(visitor(request), cookieToken)
)
_ <- ZIO.foreachDiscard(publicSessionId)(LiveConnections.disconnect(_))
yield AuthLabRoutes.login.location.seeOther
.addCookie(security.cookies.expire(SessionCookieName))
revokeAndDisconnect.catchAll(error =>
ZIO.logErrorCause("Authentication reset fanout failed", Cause.fail(error)) *>
ZIO.succeed(
Response.internalServerError
.addCookie(security.cookies.expire(SessionCookieName))
)
)
}View source (documentation/site/src/scalive/docs/auth/AuthLab.scala:75-114)
Reset is idempotent: a missing, stale, or already-revoked application session
still clears the browser cookie. When invalidation succeeds, it returns to the
login page. It affects only the opaque application session and attempt record
associated with that visitor.
The handler first revokes the durable session record and then calls
LiveConnections.disconnect(publicSessionId). Reversing this order could let a
reconnecting socket resume the session before revocation becomes authoritative.
disconnect promptly closes every local tab registered to that application
session ID. The browser reconnects and reruns connected admission, which rejects
the now-revoked claim. A stale signed bootstrap token therefore cannot restore
the session.
Connected resources remain per-LiveView even when those tabs share one application-session ID. Closing every matching physical connection finalizes each lifecycle independently; a resource shared by the logical session instead belongs in a service with explicit leases or reference counting. See Asynchronous work, subscriptions, and connected resources.
Provide One Shared Authentication Service
The HTTP handlers and protected Live routes must use the same AuthService
instance. The admitted session also makes LiveConnections[PublicSessionId] a
visible application environment requirement. For one backend instance, provide
both layers once at Server.serve:
Server.serve(routes).provide(
Server.default,
AuthService.live,
LiveConnections.local[PublicSessionId]
)The same .context API can infer one application service from a typed
constructor without an explicit service type argument:
final class ProfileLiveView(currentUser: CurrentUser, accounts: Accounts)
val profile = routes.profile.context((auth: ConnectedAuth, accounts: Accounts) =>
new ProfileLiveView(auth.currentUser, accounts)
)Only ConnectedAuth is supplied by admission. The view projects CurrentUser
from it; Accounts is resolved from the ZIO environment independently and is
never serialized into mount claims or passed to layouts.
Keep the service responsible for credentials, session authenticity, expiry,
revocation, capacity, and rate limits. Keep the session mount aspect responsible
for translating that service decision into typed mount context or a redirect. The
protected LiveView then receives CurrentUser without reading cookies or
repeating authorization logic.
Fan Out Disconnects Across Nodes
Multiple backend instances need an application-supplied
LiveDisconnectBus[PublicSessionId] adapter whose subscription broadcasts every
ID to every node. Scalive provides the interface and distributed connection
layer, not a Redis, NATS, or other transport adapter. Compose your adapter layer
like this:
def liveConnections(
bus: ZLayer[Any, Throwable, LiveDisconnectBus[PublicSessionId]]
): ZLayer[Any, Throwable, LiveConnections[PublicSessionId]] =
bus >>> LiveConnections.distributed[PublicSessionId]Received events signal local transports without being republished. Duplicate or out-of-order delivery is harmless. Publication failure is reported after local connections have been signaled. The lab expires the browser cookie and returns an error in that case; production logout code should retry from retained server state or use an outbox.
The bus accelerates invalidation; it is not authentication truth. During a network partition, an already-connected remote node may receive the notification late. Shared durable revocation still rejects new mounts. Stricter guarantees require an outbox or replay, leases, periodic revalidation, or authorization on each sensitive operation.
Production Checklist
Before adapting this lab for real users:
set secure cookies whenever the public origin uses HTTPS;
load the signing secret through validated production
ZioHttpConfigand configure its non-empty exact WebSocket origin allowlist;use a real password verifier and avoid logging submitted credentials;
choose distributed session and rate-limit storage for multiple instances;
define absolute expiry, renewal, revocation, and account-disable behavior;
configure all nodes with compatible route/session identities and signing keys;
choose a broadcast fanout adapter plus retry or outbox policy;
preserve generic login failures and bounded request bodies; and
test login, protected HTTP render, connected resumption, multi-tab logout, turn revalidation, reconnect denial, expiry, throttling, and visitor isolation.
The lab is teaching code, not a production identity system.
Related Tasks
Review phase-safe claims in Layouts, live sessions, and mount aspects.
Choose admission or a plain session aspect in Bind admission to physical connections.
Apply the complete turn boundary from Lifecycle hooks.
Wire the shared session service with Services and dependency injection.
Diagnose rejected joins and forms in Troubleshooting.