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.toml — melee 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
Secureflag 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
settingor a durable object is the tool. haltandredirectdo not return, soredirect "/login" unless session[:admin]in abeforefilter really does stop the request.
See also
- Security — the full surface.
- Reading the request — headers, cookies, the raw body.