Auth in the Box
Authentication is the scaffolding every product rebuilds, and the one where rebuilding is most dangerous: session fixation, timing attacks, rate limits, password storage. Mar’s position is that auth belongs to the framework, the way migrations do, and that the default flow should have no passwords at all.
Passwordless, by default
The built-in flow: the user types an email, receives a six-digit one-time code, types it, and has a session. No password to store, hash, leak, reuse, or reset. For the small and mid-sized products Mar targets, this is both more secure than a homegrown password table and dramatically less code.
The app supplies its own User entity and registers it once:
auth : Auth { id : Int, email : String }
auth =
Auth.config
{ entity = Backend.Users.users
, identify = \u -> u.email
, signInPage = Frontend.SignIn.page
, email = { subject = "Your sign-in code" }
, signup = \userEmail -> { email = userEmail }
, sessionDuration = Time.days 30
}
Read the fields as the answers to auth’s eternal questions. Which table holds users? How do I find one from an email (identify)? Where do I send someone with no session (signInPage)? What does a brand-new user’s row look like (signup, so first sign-in doubles as registration)? How long do sessions live?
The runtime supplies the rest: code generation and expiry, the email send (SMTP config lives in mar.json, secrets via env:), session issuance, cookie handling on the web and secure token storage on iOS, and rate limiting on both the email and the guessing side.
Protecting things
You met both halves already; here is the pattern named. Services are protected by wrapping:
services =
[ Auth.protect Shared.listTasks listTasksImpl ]
Auth.protect rejects the call with 401 before your handler runs, and otherwise hands the handler the signed-in User as an argument. The handler’s type requires that argument, so an unauthenticated path into protected logic does not exist in the compiled program. Authorization then starts from a trustworthy identity: Repo.findBy tasks { userId = user.id } scopes data to the caller because the caller is a fact, not a header you parsed yourself.
Pages are protected by the combinator: Page.protected runs the session check on entry, redirects to signInPage when it fails, and passes the User into init when it succeeds.
The sign-in page itself is ordinary MVU calling two framework services, whose outcomes are typed unions like every other outcome in the language:
-- Auth.requestCode delivers Auth.RequestOutcome:
-- Auth.CodeSent | Auth.InvalidEmail | Auth.RateLimited
-- Auth.verifyCode delivers Auth.VerifyOutcome user:
-- Auth.SignedIn user | Auth.WrongCode | Auth.TooManyAttempts
Verified (Ok (Auth.SignedIn user)) ->
( model, Auth.completeSignIn )
Verified (Ok Auth.WrongCode) ->
( { model | error = Just "That code did not match. Try again." }, Cmd.none )
Auth.completeSignIn returns the user to wherever a 401 originally interrupted them. Note the doctrine holding: WrongCode is a domain outcome a page renders, not an exception, not a status code you compare against 403 and hope.
Why this belongs in the framework
An application-level auth library can never promise what a language-level one can. Because Auth.protect and Page.protected are the only doors, and both demand the User in the types behind them, the classic failure (the one route where someone forgot the middleware) is not a lurking possibility that audits hunt for; it is unrepresentable. The session store, the rate limiter, and the cookie flags are the framework’s responsibility, tested once, hardened once, shared by every Mar app.
There is also a subtler benefit: uniformity across platforms. The same Auth.config drives the web app and the iOS app; the runtime handles each platform’s session storage idioms. You did not write auth twice, and you cannot have gotten it right only once.
That completes the fullstack tour: contracts, data, effects, failures, identity. What remains is the part users actually see.