Guide

Headers, cookies and sessions

A request brings headers and cookies; an answer carries headers and may set cookies; a session is a cookie the server signs so that the browser can hold it but not change it. This page shows each of them with a working example: reading what the browser sent, deciding by it, setting what goes back, keeping a visitor signed in, and ending that. Everything here is one program; put the parts in one file to try it.

Reading request headers

request.headers is a map of the request's headers, names in lower case. A header sent twice keeps the last. It is read in a route or a server fn; a helper gets request as an argument.

route GET /whoami {    let lang = request.headers["accept-language"] ?? "en"    {        host: request.headers.host,        language: trim(first(split(lang, ","))),        agent: request.headers["user-agent"],        ip: request.ip,    }}
fn visitor(req: map) -> str = trim(first(split(req.headers["x-forwarded-for"] ?? req.ip, ",")))route GET /ip = visitor(request)

Choosing a route by its headers

A route line can carry a request pattern. The route matches only when the headers fit; otherwise matching goes on to the next route, so a last route can answer the rest:

route GET /api/users { headers: { host: "api.example.com" } } = all_users()route POST /hooks { headers: { "x-hub-signature-256": str } } {    let sig = request.headers["x-hub-signature-256"]    if !crypto.equal(sig, `sha256=${crypto.hmac(env("HOOK_SECRET"), request.body)}`) { reply 401 { error: "bad signature" } }    handle_hook(request.json)    { ok: true }}route POST /hooks = reply 401 { error: "no signature" }

A pattern on the host is the way to serve several hosts from one program; for one database per host, see Many tenants.

Header checks as middleware

A check is a function that gives the request back or ends it with reply. It goes in a route as a pipeline stage, or before a whole set of routes as the middleware of a routes group, which also gives them a shared prefix (see Route groups). reply ends the request from anywhere, a helper included.

let api = {    key: (req) =>        req.headers.authorization == `Bearer ${env("API_TOKEN")}`            ? req            : reply 401 { error: "unauthorized" },    json: (req) =>        starts_with(req.headers["content-type"] ?? "", "application/json")            ? req            : reply 415 { error: "send JSON" },}route GET /api/private = request | api.key | (r) => { ok: true, from: r.ip }route POST /api/items = request | api.key | api.json | (r) => add_item(r.json)

The same checks once, for every route under /api:

routes /api | api.key {    route GET /private = { ok: true, from: request.ip }    routes /items | api.json {                       // groups nest: one more check for these        route POST / = add_item(request.json)    }}

As a group's middleware, a check that gives back a map of its own, { error: "unauthorized" }, rather than replying it, is a 500 that names it (since 0.7: the route ran).

For a page rather than an API, the check redirects instead: a reply with a status and a redirect goes to the login page from inside the helper (see Sessions).

Setting response headers

A route sets headers by returning respond(status, body, headers). Streams and events take theirs in their options.

route GET /api/stats = respond(200, site_stats(), { "cache-control": "public, max-age=60" })route GET /export.csv = respond(200, csv_text(), {    "content-type": "text/csv; charset=utf-8",    "content-disposition": "attachment; filename=\"export.csv\"",})route GET /log = stream((out) => { for line in tail_log() { out(line) } },    { type: "text/plain", headers: { "cache-control": "no-store" } })

reply status respond(...) keeps the headers and body of the respond with the new status, so a helper can answer with headers too:

fn open_for_business() {    if env("MAINTENANCE") == "1" {        reply 503 respond(503, "Back in a minute", { "retry-after": "60" })    }}route GET /checkout { open_for_business(); <CheckoutPage/> }

CORS. Browsers ask with an OPTIONS request before a cross-site call. Quillo answers an OPTIONS on a path a route has with 204 and Allow, but writes no access-control-* header, so the browser refuses the call. Declare the route and send the headers on the answers too:

let CORS = {    "access-control-allow-origin": "https://app.example.com",    "access-control-allow-methods": "GET, POST",    "access-control-allow-headers": "content-type, authorization",}route OPTIONS /api/*rest = respond(204, null, CORS)route GET /api/items = respond(200, items(), CORS)

Content Security Policy. A header on the page's route, or set by the proxy in front, decides where the page may load from:

route GET /app = respond(200, <Page><Home/></Page>, { "content-security-policy": "default-src 'self'; img-src 'self' data:" })

Cookies

set_cookie(name, value, options) adds a Set-Cookie to the answer, whatever the route returns, from anywhere in the request. cookie(name) reads one the browser sent, or null; request.cookies is all of them. A page that reads either, or the cookie or authorization header, is the visitor's: it goes out with Cache-Control: private and Vary: Cookie, as one made with session() does, so a cache in front keeps it for no one else (since 0.7: one that read request.cookies or the headers was not). A route that sets cache-control itself keeps its own.

route POST /theme { form: { theme: str } } {    set_cookie("theme", theme, { max_age: "1y" })    redirect("/")}route GET / = <Page theme={cookie("theme") ?? "light"}><Home/></Page>route POST /theme/reset { delete_cookie("theme"); redirect("/") }

The options, and what a cookie is without them:

optiondefaulttakes
max_agenone: the cookie lasts until the browser closesseconds, or a duration: "30d" with s, m, h, d, w, y
path/ (or the prefix the program is served under: quillo run main.qwe --base /shop, or a mounted program's path)a path starting with /, with no space, ;, control character or letter past ASCII (percent-encode them)
domainthe host onlya host name, letters, digits, . and -
http_onlytrue: the page's scripts cannot read itfalse to let them
securetrue: sent over HTTPS onlyfalse
same_site"Lax""Lax", "Strict" or "None"

A cookie the page's script reads, for a preference the browser applies before the page is rendered again:

route POST /tz { form: { tz: str } } {    set_cookie("tz", tz, { max_age: "1y", http_only: false })    { ok: true }}

Keep secrets out of plain cookies: the browser, and anything on the machine, can read them. For "remember me", store a random token in the database and put only the token in the cookie:

route POST /remember with db, random, time {    let id = session().user ?? reply 401 "Log in first"    let token = crypto.token()    db.exec("insert into remember (token, user_id, expires) values (?, ?, ?)", token, id, now() + 90 * 24 * 60 * 60 * 1000)    set_cookie("remember", token, { max_age: "90d", same_site: "Strict" })    { ok: true }}fn user_from_remember() {    let token = cookie("remember") ?? return null    let row = db.one("select user_id from remember where token = ? and expires > ?", token, now())    row.user_id}

Sessions

A session is a map the server keeps for a visitor, in a cookie it signs with HMAC-SHA256. The browser holds it and sends it back; it can read it, so keep secrets out, but it cannot change it, since the signature would no longer match.

route POST /login { form: { email: str, password: str } } with db, random {    let user = db.one("select * from users where email = ?", email)    let hash = user.password_hash ?? crypto.hash_password("not anyone's")   // same work, user or not    if user == null || !crypto.verify_password(password, hash) { reply 401 "Wrong email or password" }    set_session({ user: user.id })    redirect("/")}route GET /me with db {    let id = session().user ?? reply 401 "Log in first"    db.one("select id, name from users where id = ?", id)}route POST /logout { end_session(); redirect("/") }

Requiring a session. One helper, used by every page that needs a visitor. reply with a status and a redirect ends the request from inside it:

fn me() -> map? = session().user == null ? null : db.one("select id, name from users where id = ?", session().user)fn me_or_login(back: str) = me() ?? reply 303 redirect(`/login?next=${url_encode(back)}`)route GET /account with db = <AccountPage me={me_or_login("/account")}/>route GET /orders with db = <OrdersPage me={me_or_login("/orders")}/>

For a server fn, which a component calls from the browser, answer with an error value the page can show instead of a redirect:

server fn save_note(text: str) -> map {    let id = session().user ?? return err("Sign in first")    db.exec("insert into notes (user_id, text) values (?, ?)", id, text)}

Signing in from a component. A server fn can call set_session too: the cookie is set on the answer to the call, and the pages the visitor opens next carry it.

server fn sign_in(email: str, password: str) -> map {    let user = db.one("select * from users where email = ?", email)    let hash = user.password_hash ?? crypto.hash_password("not anyone's")    if user == null || !crypto.verify_password(password, hash) { return err("Wrong email or password") }    set_session({ user: user.id })    { name: user.name }}component LoginForm() {    state email = ""    state password = ""    state problem = null    <form onsubmit={() => {        let r = sign_in(email, password)        if is_err(r) { problem = r.error } else { navigate("/") }    }}>        <input name="email" value={email}/>        <input name="password" type="password" value={password}/>        <p.hint>{problem}</p>        <button>Sign in</button>    </form>}

How long it lasts, and renewing. A session ends when its time is up, however often the visitor comes back: session() reads the cookie and never sends it again. For a session that lasts while it is used, call set_session again on the requests that count as use. Once a day is enough, and keeps the cookie from being sent with every answer:

fn touch() {    let s = session()    let day = 24 * 60 * 60 * 1000                      // now() is milliseconds    if s.user != null && now() - (s.at ?? 0) > day {        set_session({ ...s, at: now() }, { max_age: "30d" })    }}route GET /dashboard { touch(); <Dashboard me={me_or_login("/dashboard")}/> }

Signing out everywhere. end_session() removes the cookie from one browser; a copy someone kept stays valid until it expires. To end sessions on the server (a stolen laptop, a password change), keep a session id in the session and check it against the database:

route POST /login/anywhere { form: { email: str, password: str } } with db, random, time {    let user = check_password(email, password) ?? reply 401 "Wrong email or password"    let sid = crypto.token()    db.exec("insert into sessions (id, user_id, created) values (?, ?, ?)", sid, user.id, now())    set_session({ user: user.id, sid })    redirect("/")}fn me_checked() -> map? {    let s = session()    if s.sid == null { return null }    db.one("select u.id, u.name from sessions s join users u on u.id = s.user_id where s.id = ?", s.sid)}route POST /logout/everywhere with db {    let id = session().user ?? reply 401 "Log in first"    db.exec("delete from sessions where user_id = ?", id)    end_session()    redirect("/")}

The key. Set QUILLO_SECRET to a long random string (crypto.token() makes one) and use the same one on every server behind a load balancer. Without it a random key is made at start, so sessions end when the server restarts. The server warns at start when the secret is shorter than 32 bytes: a short one can be guessed offline from one signed cookie.

The cookie itself. In production the session cookie is __Host-quillo_session: browsers take a cookie with that prefix only from the very host, over HTTPS, so a sibling subdomain (another tenant's site) cannot plant one. With --watch, --plain-http or in a mounted program it is quillo_session. The signature covers the expiry, the tenant and the program's mount point, so a session is no session past its time, for another tenant, or in another mounted program. A request with two session cookies that are both valid here has no session: one of them was planted.

Putting it together

A small program with a sign-in, a theme cookie, a sliding session and an API behind a token. Run it, then curl -c jar -b jar through the routes.

db.open("app.db")db.migrate("migrations")let api = {    key: (req) => req.headers.authorization == `Bearer ${env("API_TOKEN")}` ? req : reply 401 { error: "unauthorized" },}// Only a path on this site: "//evil.example" starts with "/" too, and is another site.fn local(next: str) -> str = starts_with(next, "/") && !starts_with(next, "//") && !contains(next, "\\") ? next : "/"fn me() -> map? = session().user == null ? null : db.one("select id, name from users where id = ?", session().user)fn me_or_login(back: str) = me() ?? reply 303 redirect(`/login?next=${url_encode(back)}`)fn touch() {    let s = session()    let day = 24 * 60 * 60 * 1000    if s.user != null && now() - (s.at ?? 0) > day { set_session({ ...s, at: now() }, { max_age: "30d" }) }}view Page(theme: str, children) =    <html><body class={`theme-${theme}`}>{children}</body></html>route GET / with db, time {    touch()    <Page theme={cookie("theme") ?? "light"}>        {me() != null ? <p>Hello, {me().name}. <a href="/account">Account</a></p> : <a href="/login">Sign in</a>}        <form method="post" action="/theme">            <select name="theme"><option>light</option><option>dark</option></select>            <button>Change theme</button>        </form>        {me() != null && <form method="post" action="/logout"><button>Sign out</button></form>}    </Page>}route GET /login ? { next: str = "/" } =    <Page theme={cookie("theme") ?? "light"}>        <form method="post" action="/login">            <input type="hidden" name="next" value={next}/>            <input name="email"/> <input name="password" type="password"/>            <button>Sign in</button>        </form>    </Page>route POST /login { form: { email: str, password: str, next: str = "/" } } with db, random, time {    let user = db.one("select * from users where email = ?", email)    let hash = user.password_hash ?? crypto.hash_password("not anyone's")    if user == null || !crypto.verify_password(password, hash) { reply 401 "Wrong email or password" }    set_session({ user: user.id, at: now() })    redirect(local(next))}route GET /account with db = <Page theme={cookie("theme") ?? "light"}><p>Account of {me_or_login("/account").name}</p></Page>route POST /theme { form: { theme: str } } { set_cookie("theme", theme, { max_age: "1y" }); redirect("/") }route POST /logout { end_session(); redirect("/") }route GET /api/me = request | api.key | (r) => respond(200, { ip: r.ip, user: session().user }, { "cache-control": "no-store" })

The next a login form carries is checked by local before it is followed, so a link from elsewhere cannot send a visitor to another site after signing in: starting with / is not enough, since a browser takes //evil.example as another host, and redirect sends a URL to another host as it is given (encoded where a URL must be). It refuses only a javascript:, vbscript: or data: URL, and keeps a path of the site's own on the site: /\evil.example is written /%5Cevil.example, and .. is taken out. Signing out and the theme are forms too, posting to their routes: a link cannot log anyone out, since a GET would let any page do it. The migrations/ folder holds the users table, migrations/001_users.sql:

create table users (id integer primary key, name text, email text unique, password_hash text);

A user to sign in as, made with a script beside it (quillo run seed.qwe), and the routes followed with curl (--plain-http, so the session cookie is not Secure):

db.open("app.db")db.migrate("migrations")db.exec("insert into users (name, email, password_hash) values (?, ?, ?)", "Ann", "ann@example.com", crypto.hash_password("pw"))
quillo run main.qwe --port 8080 --plain-httpcurl -c jar -b jar localhost:8080/login -H "Origin: http://localhost:8080" -d "email=ann@example.com&password=no"# 401 Wrong email or passwordcurl -c jar -b jar localhost:8080/login -H "Origin: http://localhost:8080" -d "email=ann@example.com&password=pw&next=/account"# 303 to /account, with the session cookiecurl -c jar -b jar localhost:8080/account                # Account of Anncurl -c jar -b jar -X POST localhost:8080/logout -H "Origin: http://localhost:8080"curl -c jar -b jar -i localhost:8080/account             # 303 to /login?next=%2Faccount

See Database for how migrations run.

Thread
13Useful? Like it, or add to its thread below.

Thread 0 posts

On Headers, cookies and sessions: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.