Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Ordinary HTTP forms and redirects

Before You Start

Start with a typed form or ordinary HTML controls, a GET or POST RoutePattern that can receive them, and one validated ZioHttpConfig shared by the Live and sibling ZIO HTTP routes.

Choose Ordinary HTTP Deliberately

Use an ordinary browser form when submission should have normal HTTP semantics: changing cookies, crossing an authentication boundary, downloading a response, leaving LiveView, or applying Post/Redirect/Get. Keep purely interactive validation and in-page updates as Live events.

Define the target as a ZIO HTTP RoutePattern and derive a checked FormAction:

scala
val SearchRoute  = Method.GET / "search"
val SessionRoute = Method.POST / "session"

val searchAction  = FormAction.from(SearchRoute)
val sessionAction = FormAction.from(SessionRoute)

Checked actions accept only GET and POST, format typed path parameters, URL encode the resulting path, and mark POST for CSRF injection. FormAction.from throws FormAction.EncodingException for unsupported methods or path encoding failure; use FormAction.fromEither when dynamic parameters should keep EncodeError in an Either.

FormAction.unsafe(method, href) is an explicit escape hatch for an already encoded or external URL. It performs no route, origin, or path validation and never requests Scalive CSRF injection, even for POST. Prefer checked actions for application routes.

Render A Reusable HTTP Form

Use the rooted form's http method when controls and codecs already come from a FormDefinition:

scala
val loginForm = LoginForm.Definition.initial()
val email     = loginForm.field(LoginForm.Email)
val password  = loginForm.field(LoginForm.Password)

loginForm.http(FormAction.from(SessionRoute))(
  idAttr := "login-form",
  label(forId := email.id, "Email"),
  email.email(autoComplete := "username", required := true),
  label(forId := password.id, "Password"),
  password.password(autoComplete := "current-password", required := true),
  button(typ := "submit", "Sign in")
)

Use Form.http directly when no form model is needed:

scala
Form.http(FormAction.from(Method.POST / "session" / "reset"))(
  button(typ := "submit", "Sign out")
)

Form.http owns the action and method attributes and rejects direct overrides. It does not add phx-change, phx-submit, or phx-trigger-action. The rooted variant does not render controls automatically; pass them as modifiers.

Both checked GET and POST actions render with native browser methods. GET is appropriate only for safe, idempotent retrieval and puts successful controls in the URL query. POST is appropriate for state changes. The current HttpFormDecoder decodes a URL-encoded request body and always validates CSRF, so it is intended for checked POST handlers, not GET query decoding. Decode and bound GET query parameters at the route boundary with the query API chosen by your application.

Share Security And Inject CSRF

Create one validated transport config and share it between Live transport routes and the HTTP route owner:

scala
val transportConfig = ZioHttpConfig(
  signingSecret = config.signingSecret,
  sessionMaxAge = java.time.Duration.ofMinutes(30),
  secureCookie = config.publicHttps,
  allowedWebSocketOrigins = Set(WebSocketOrigin.https("example.com"))
).fold(error => throw IllegalArgumentException(error.toString), identity)

val security = LiveSecurity(transportConfig)
val application = Live.router(liveRoutes*)
val routes = ZioHttp.routes(application, security) ++ httpRoutes(security)

A checked POST rendered through Form.http is marked for Scalive's hidden CSRF token injection. Injection occurs only while rendering through transport routes configured with that validated config. The matching browser cookie and submitted token are validated later with security.csrf, derived from the same validated config.

Checked GET actions and all unsafe actions are not marked for token injection. Do not treat Form.http alone as request protection: the HTTP handler must still validate CSRF for state-changing requests. Set secureCookie = true when the browser-facing origin uses HTTPS; Scalive does not infer that from forwarding headers.

Decode A Bounded POST Body

Build one reusable decoder from the same typed definition used by the form:

scala
private val FormMaxBytes = 4096L

private val loginFormDecoder = HttpFormDecoder.urlEncoded(
  LoginForm.Definition,
  maxBytes = FormMaxBytes,
  csrf = security.csrf
)

private val loginValueDecoder = HttpFormDecoder.urlEncodedValue(
  LoginForm.Definition,
  maxBytes = FormMaxBytes,
  csrf = security.csrf
)

urlEncoded accepts only application/x-www-form-urlencoded, honors its declared charset with UTF-8 as the default, and reads at most maxBytes + 1 bytes to detect overflow. It preserves duplicate fields in wire order, validates CSRF, and only then runs the application form projection. Given a FormDefinition, urlEncoded always returns a submitted Definition.Form after transport and CSRF checks, even when its result contains domain validation errors. This is useful when the HTTP handler will render the submitted values and visible feedback again.

urlEncodedValue instead requires a valid domain result. It returns the decoded domain value on success and maps invalid form output to Error.Validation. Choose it when the handler should run only for valid input. The separate urlEncoded(FormCodec, ...) overload remains the explicit low-level escape hatch for applications intentionally decoding a raw codec rather than a definition.

The decoder does not check the request method or route, authenticate or authorize a user, rate-limit requests, sanitize strings, or make an unsafe target secure. Keep those checks in the route handler and service layer. Choose a route-specific bound; the maximum applies to the encoded body, not decoded string sizes or downstream work.

Map Decoder Errors To Responses

ZIO HTTP handlers commonly return UIO[Response], an effect with no typed failure, or a wider ZIO[R, E, Response] when services and failures remain in the environment and error channels. HttpFormDecoder.respond lets both the success and semantic-validation callbacks keep that wider effect while mapping transport and security rejections to fixed responses.

Call decode(request) when the handler needs to pattern match the typed error channel directly. For the common case, respond runs the success callback only after every stage succeeds. A semantic validation failure can return the signed flash redirect defined below:

scala
private def createSession(request: Request): UIO[Response] =
  loginValueDecoder.respond(
    request,
    onValidation = _ => invalidLogin,
    onRejected = error => ZIO.logWarning(s"login form rejected code=${error.code}")
  ) { credentials =>
    sessions.create(credentials).map { session =>
      Dashboard.location.seeOther.addCookie(
        security.cookies.make("session", session.token)
      )
    }
  }

Failures remain distinct:

  • Error.Body covers oversized or unreadable bodies.

  • Error.Representation covers content type, URL encoding, and unsupported field representations.

  • Error.Csrf covers missing or invalid browser-bound tokens.

  • Error.Validation carries the typed form's FormErrors.

The default mapping is 413 for oversized bodies, 415 for invalid content type, 400 for other body or representation failures, and 403 for CSRF rejection. onValidation chooses the application response for semantic validation errors through an effect. For a plain response, use onValidation = _ => ZIO.succeed(Status.UnprocessableEntity.toResponse).

onRejected observes every decoder rejection exactly once and must complete before the rejection response is produced. It cannot choose a different response; log the stable low-cardinality error.code, not raw bodies, credentials, tokens, or cookies. Transport and CSRF rejections never invoke onValidation or the success callback. Successful decoding invokes neither rejection callback. An observer defect or interruption aborts response production; respond does not substitute a fallback HTTP response.

Callbacks are deferred until the returned effect runs. Environment requirements and typed failures from either response callback remain in that effect; they are not decoder rejections and do not cause another onRejected invocation. Map application failures to HTTP responses at the appropriate application boundary.

Validate Live, Then Trigger HTTP

Sometimes a form should provide Live validation first, then perform an ordinary POST only after the typed value is valid. Keep that transition explicit in the model:

scala
final case class Model(form: LoginForm.Definition.Form, submitHttp: Boolean = false)

case Msg.Submit(event) =>
  event.form.result match
    case Right(_) =>
      ZIO.succeed(model.copy(
        form = event.form,
        submitHttp = true
      ))
    case Left(_) =>
      ZIO.succeed(model.copy(
        form = event.form,
        submitHttp = false
      ))

Render an ordinary action, a Live submit binding, and the conditional trigger on the same form:

scala
model.form.http(FormAction.from(SessionRoute))(
  idAttr := "login-form",
  model.form.onSubmit(Msg.Submit(_)),
  model.form.triggerHttpSubmitWhen(model.submitHttp),
  // controls
  button(typ := "submit", submission.replaceTextWith("Signing in..."), "Sign in")
)

The first submit is a Live event. When the next patch renders phx-trigger-action, Phoenix hands the form back to the browser for normal HTTP submission. The POST handler must decode, validate CSRF, validate the typed fields, authenticate, and authorize again. Live validation is user feedback, not a security boundary, and the HTTP request can be forged or changed.

Only set the trigger after the intended successful transition. Clear it on invalid events or when returning to editable state. The trigger requires the ordinary action and method supplied by Form.http; it is never added automatically.

Redirect HTTP Flash Into A Live Route

For a failed POST followed by a Live page, issue a 303 with a signed flash cookie through the shared security value:

scala
val LoginError = FlashKind("error")

private def invalidLogin: UIO[Response] =
  security.flash.seeOther(
    Login.location,
    LoginError -> "The sign-in request was invalid. Please try again."
  )

Render the same kind in the destination LiveView:

scala
flash(LoginError)(message =>
  div(role := "alert", message)
)

HttpFlash.seeOther accepts a typed LiveLocation; use seeOtherUnsafe only for an independently validated URL. Flash values are signed but not encrypted, so never put secrets in them. The next successful disconnected Live render transfers valid values into the signed Live session and expires the browser cookie. This is browser consume-on-render behavior, not server-side replay prevention for copied cookies.

Use fixed, user-safe messages in authentication flows. Log diagnostic context separately so redirects do not disclose whether an account exists or expose low-level decoder details.