Cookies
A cookie is a small named value the browser stores for your site and sends back with every later request. They are how a site remembers a theme, a language, or which session a visitor belongs to.
The howdy/cookie module covers both directions. Reading works like
howdy/query: a function looks up one cookie, hands your handler its value,
and answers 400 on your behalf when it is missing or malformed. Writing is
a step in the response pipeline.
Reading and writing
Read a cookie with use, and set or delete one by piping the response:
import howdy/controller.{type Context}
import howdy/cookie
pub fn preferences_controller() {
controller.new("preferences")
|> controller.get("/", show_theme)
|> controller.post("/dark", use_dark_theme)
|> controller.delete("/", clear_theme)
}
fn show_theme(ctx: Context) {
use theme <- cookie.string_or(ctx, "theme", default: "system")
controller.text(ctx, theme)
}
fn use_dark_theme(ctx: Context) {
let options =
cookie.defaults()
|> cookie.max_age(60 * 60 * 24 * 30)
controller.text(ctx, "Preference saved")
|> cookie.set("theme", "dark", options)
}
fn clear_theme(ctx: Context) {
controller.text(ctx, "Preference cleared")
|> cookie.delete("theme", cookie.defaults())
}
A first visit to /preferences answers system. After a POST to
/preferences/dark the browser holds the cookie for thirty days, and the
same page answers dark.
Reading cookies
Cookie values are always strings, and there are three ways to read one. They differ only in what happens when the cookie is absent:
-
cookie.stringis required. An absent cookie answers400. -
cookie.optional_stringgives you anOption:Some(value)when the cookie was sent,Nonewhen it was not. -
cookie.string_ortakes adefault:and uses it when the cookie is absent.
A bad cookie stops the handler and answers 400 with a JSON body that says
what was wrong. With use theme <- cookie.string(ctx, "theme"):
-
No
themecookie answers{"error":"missing cookie theme"}. -
Two
themecookies answer{"error":"cookie theme must occur only once"}. -
A value that is not valid percent encoding, such as
theme=%zz, answers{"error":"cookie theme has invalid encoding"}.
As with query strings, the optional and default forms are only forgiving
about absence. A duplicated or badly encoded cookie answers 400 from all
three. An empty value counts as present: theme= gives "", not the
default.
Remember that a cookie comes from the client, so treat its value like any
other input. A visitor can edit, delete or invent cookies, and a required
cookie being absent is normal on a first visit. Prefer string_or or
optional_string unless a missing cookie really is a bad request.
Setting cookies
cookie.set takes the response, a name, a value and the options, and
returns the response with a set-cookie header added. Every other header is
kept, including cookies set earlier in the pipeline, so you can set several:
controller.text(ctx, "Welcome back")
|> cookie.set("theme", "dark", cookie.defaults())
|> cookie.set("language", "en-GB", cookie.defaults())
Values are percent-encoded when written and decoded when read, so spaces,
punctuation, Unicode and literal percent signs all survive the round trip.
Setting "dark mode 100%" sends theme=dark%20mode%20100%25, and
cookie.string hands back the original text. If something other than Howdy
writes a cookie you read, such as JavaScript in the browser, it should use
the same encoding.
Cookies are limited to about 4KB each and travel with every request, so keep them small: store an identifier, not the data it identifies.
Cookie options
cookie.defaults() is a safe starting point, and the setters adjust it:
let options =
cookie.defaults()
|> cookie.max_age(60 * 60 * 24 * 30)
|> cookie.same_site(cookie.Strict)
|> cookie.path("/preferences")
The defaults give a cookie with these attributes:
Path=/; Secure; HttpOnly; SameSite=Lax
-
max_ageis the lifetime in seconds. Without it the cookie lasts until the browser closes. Zero or a negative number expires it at once. -
securecontrolsSecure, which keeps the cookie off plain HTTP. It is on by default. -
http_onlycontrolsHttpOnly, which hides the cookie from JavaScript. It is on by default; turn it off only when a script in the page needs to read the value. -
same_sitetakescookie.Lax,cookie.Strictorcookie.None, and decides whether the cookie is sent on requests that start on another site.Lax, the default, sends it when a visitor follows a link to you but not on cross-site form posts or embedded requests. -
pathlimits the cookie to URLs under a path. It defaults to/, the whole site. -
domainshares the cookie with subdomains. Leave it unset to keep the cookie on the current host only.
Browsers refuse a Secure cookie on a plain http:// page, with an
exception for localhost. If you develop against another host name over
HTTP, turn it off there and nowhere else:
cookie.defaults() |> cookie.secure(False)
Options are configuration, not user input, so mistakes in them panic rather
than answering 400. That covers an invalid cookie name, a path that does
not start with /, a domain with characters outside letters, digits, dots
and hyphens, and SameSite=None without Secure, which browsers reject.
Cookie values never panic, because set encodes them.
Deleting cookies
There is no way to tell a browser to delete a cookie outright. Instead
cookie.delete overwrites it with an empty value, Max-Age=0 and an expiry
in the past, and the browser discards it:
theme=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax
A browser only replaces a cookie with the same name, path and domain, so
pass delete the same path and domain you used when setting it. A cookie
set with cookie.path("/preferences") is not removed by a delete that uses
the default path of /.
Sessions and signing
These functions read and write cookies. They do not sign them, encrypt them
or check that a session is real, so never trust a cookie to say who someone
is. A value such as user_id=42 can be changed to user_id=1 by anyone.
For authentication, store a long random session identifier in the cookie,
keep the session itself on the server, and let a
guard look it up and answer
401 when it is missing or unknown. Keep the defaults for that cookie:
HttpOnly stops scripts stealing it, Secure keeps it off plain HTTP, and
SameSite=Lax stops other sites posting forms with it attached. Add the
CSRF middleware as well, which rejects cross-site
writes outright; the two fail in different ways.
Testing cookies
howdy/testing has a helper for each direction. testing.cookie adds a
cookie to a request, encoding the value the same way cookie.set does:
import howdy/testing
pub fn theme_is_read_test() {
let res =
testing.get("/preferences")
|> testing.cookie("theme", "dark")
|> testing.send(app())
assert testing.text(res) == "dark"
}
testing.cookies lists every cookie a response sets, as decoded name and
value pairs in the order they were set. A deleted cookie appears with an
empty value:
import gleam/http
pub fn theme_is_saved_test() {
let res =
testing.request(http.Post, "/preferences/dark")
|> testing.send(app())
assert testing.cookies(res) == [#("theme", "dark")]
}
Attributes such as Path and Max-Age are left out of that list. To check
them, read the raw set-cookie headers from res.headers.
