Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Logging people in

A signed cookie, a constant-time compare, and three patterns that cover most small apps. There is no user system in the box.

melee gives you a signed session, a CSRF check, secure_equal? and random_token. Everything above that — users, passwords, OAuth, passkeys — you write, or you do not need.

What the session is

A cookie named melee_session: base64url JSON plus an HMAC-SHA256 signature, keyed on a secret the server generates per app. HttpOnly, SameSite=Lax, and Secure when the request arrived over HTTPS. A tampered cookie is treated as no session at all.

session[:user_id] = "42"     # values are Strings; an Integer is stored as "42"
session[:user_id]            # => "42"
session.delete(:user_id)
session.clear

Signed is not encrypted. The visitor can read everything in their own session. Put an id or a flag there; never a password, an API token, or anything you would not show them. Keep it small — the cookie travels on every request, and browsers drop cookies over about 4 KB.

Pattern 1: one shared secret

The right answer for an admin page on a personal app. This is what the tutorial builds.

post "/login" do
  if secure_equal?(params.fetch(:secret), ENV["ADMIN_SECRET"].to_s)
    session[:admin] = "1"
    redirect "/admin"
  else
    status 401
    render :login, error: "That secret is wrong."
  end
end

before "/admin" do
  redirect "/login" unless session[:admin]
end

post "/logout" do
  session.clear
  redirect "/"
end

secure_equal? takes the same time whether the guess was wrong in the first character or the last, so it does not leak how close an attacker is getting. Use it for every secret, token and signature comparison; == is fine for everything else.

The secret lives in the environment, set with melee env ADMIN_SECRET <value> and read as ENV["ADMIN_SECRET"]. Never in app.rb, never in melee.tomlmelee push uploads the whole directory.

Pattern 2: a long-lived URL token

For a device that cannot type a password — a wall tablet, a TV, a shared display.

get "/d/:token" do
  halt 404 unless secure_equal?(params.fetch(:token), display_token)
  header "Cache-Control", "no-store"
  render :display, layout: false, events: todays_events
end

post "/admin/rotate" do
  setting "display_token", random_token
  redirect "/admin"
end

def display_token = setting("display_token") || setting("display_token", random_token)

random_token gives a URL-safe token with 24 bytes of entropy by default, which is 32 characters of base64 — pass a number for more. setting stores it in the app database. halt 404 rather than 403 so the URL does not confirm it exists.

Give the holder a way to rotate it, and treat the URL as the credential it is — it will end up in a browser history and a screenshot.

Pattern 3: accounts with passwords

Possible, and the place where melee’s constraints bite hardest. Read this whole section before deciding to have user accounts at all.

There are no gems, so no bcrypt, argon2 or scrypt. Worse, OpenSSL::PKCS5 does not exist in a compiled app, so the obvious fallback does not work either:

# Works under `melee dev`. Raises NoMethodError in the deployed binary.
OpenSSL::PKCS5.pbkdf2_hmac(password, salt, 600_000, 32, OpenSSL::Digest.new("SHA256"))

That is the worst shape a bug can have on this platform — right in development, broken in production — so it is worth stating plainly. OpenSSL::Digest.new(...) and OpenSSL::Digest::SHA256.new are missing too.

What is available in both runtimes is OpenSSL::HMAC.digest("SHA256", key, data), and PBKDF2 is a short loop over it:

# lib/password.rb
# frozen_string_literal: true
require "openssl"
require "base64"

ITERATIONS = 200_000

# PBKDF2-HMAC-SHA256, one 32-byte block, written out because OpenSSL::PKCS5 is not available in a compiled
# app. Produces byte-identical output to OpenSSL's own PBKDF2 for the same inputs.
def pbkdf2(password, salt, iterations = ITERATIONS)
  block = OpenSSL::HMAC.digest("SHA256", password, salt + "\x00\x00\x00\x01")
  acc = block.bytes
  i = 1
  while i < iterations
    block = OpenSSL::HMAC.digest("SHA256", password, block)
    b = block.bytes
    j = 0
    while j < acc.size
      acc[j] ^= b[j]
      j += 1
    end
    i += 1
  end
  Base64.urlsafe_encode64(acc.pack("C*"), padding: false)
end

Measured in a compiled binary: 1,000 iterations in 2.4 ms, 10,000 in 18 ms, 100,000 in 106 ms. So 200,000 iterations costs roughly a fifth of a second per sign-in. Measure it on your own machine rather than trusting that.

post "/signup" do
  salt = random_token(16)
  db.run "INSERT INTO users (email, salt, hash) VALUES (?, ?, ?)",
         params.fetch(:email), salt, pbkdf2(params.fetch(:password), salt)
  redirect "/login"
end

post "/login" do
  user = db.first("SELECT id, salt, hash FROM users WHERE email = ?", params.fetch(:email))
  if user && secure_equal?(pbkdf2(params.fetch(:password), user["salt"].to_s), user["hash"].to_s)
    session[:user_id] = user["id"].to_s
    redirect "/"
  else
    status 401
    render :login, error: "Wrong email or password."
  end
end

Three things to be honest about:

  • PBKDF2 is acceptable, not excellent. A memory-hard function would be better and is not available at any iteration count.
  • Those iterations burn CPU inside a request that holds one of the app’s four slots, under a 128 MB cap.
  • You are hand-rolling a password hash, which is not a sentence anyone enjoys writing.

If an app matters enough to need real accounts, consider putting an identity provider in front of it, or building it somewhere that has bcrypt.

CSRF

On by default for POST, PUT, PATCH and DELETE that arrive with a session cookie. Every form needs <%= csrf_field %>; a request without it gets a 403 before any filter or route runs. For JavaScript, put csrf_token in an X-CSRF-Token header.

Exempt a route only when something else authenticates the caller:

post "/hooks/stripe", csrf: false do
  signature = request.header("Stripe-Signature").to_s
  halt 400 unless secure_equal?(signature, expected_signature(request.body))
  # ...
end

Getting the basics right

  • Put TLS in front. melee-server speaks plain HTTP. A session cookie over HTTP is readable by anyone on the path, and the Secure flag is only set when the request arrived over HTTPS. See Keeping it safe.
  • Do not put roles in the session and trust them forever. The cookie lasts 30 days; check against the database on the requests that matter.
  • Rate-limit by hand if you need it. Nothing throttles login attempts for you. A counter in setting or a durable object is the tool.
  • halt and redirect do not return, so redirect "/login" unless session[:admin] in a before filter really does stop the request.

See also