Cookies¶
fn signIn(c: *nilo.Ctx, sessions: *Sessions) !void {
try c.setCookie(.{ .name = "session", .value = try sessions.open() });
}
fn me(c: *nilo.Ctx) !?User {
const token = c.cookie("session") orelse return null;
...
}
Reading walks the Cookie header where it lies and allocates nothing, so a
request that carries cookies costs the same as one that does not. A request
that splits its cookies across two Cookie headers — which HTTP/2 clients do —
is looked through in full.
The value comes back as it was sent¶
nilo does not decode a cookie value. RFC 6265 makes it opaque bytes, and every framework layers its own encoding on top — percent, base64, signed-then-base64 — so guessing would corrupt the ones that guessed otherwise.
The one thing that is stripped is surrounding quotes, because RFC 6265 allows
name="value" and some writers use it.
If your front end encoded it, you decode it¶
This is the habit that does not transfer. Node's cookie-parser
percent-decodes, and so do Gin's c.Cookie and Fiber's c.Cookies. nilo does
not, and nothing anywhere reports the difference — you get a string, it is
just not the string the browser was holding.
The way it bites is a page that wrote the cookie itself:
document.cookie = `name=${encodeURIComponent("Ana Wijaya")}`;
JavaScript reads Ana Wijaya back through decodeURIComponent. Zig reads
Ana%20Wijaya, and a comparison against the name in your database quietly
fails.
If your cookie is encoded, decode it yourself. It is one call, and it allocates only when there is something to decode:
fn me(c: *nilo.Ctx, arena: std.mem.Allocator) !?Profile {
const raw = c.cookie("name") orelse return null;
const name = try nilo.percent.decode(arena, raw, false);
...
}
The last argument is whether + means a space. For a cookie it does not — that
is a form-encoding rule — so pass false.
A session token does not need any of this. Base64 and hex go through untouched,
which is why the sessions in examples/forms
never call this.
Setting one¶
try c.setCookie(.{ .name = "session", .value = token });
goes out as
Set-Cookie: session=…; Path=/; Secure; HttpOnly; SameSite=Lax
The defaults are the careful ones, so turning a protection off is a visible line rather than a forgotten one:
| Default | ||
|---|---|---|
path |
"/" |
the whole site, not the path that happened to set it |
domain |
"" |
this host, no subdomains |
max_age |
null |
a session cookie — gone when the browser closes |
expires |
"" |
an HTTP-date, if you have one. max_age needs no clock |
secure |
true |
HTTPS only |
http_only |
true |
kept away from JavaScript |
same_site |
.lax |
.strict, .lax, .none, .unset |
SameSite=Lax is what stops a form on another site from posting with the cookie. It does not stop a page on another subdomain of your own site, and .none turns it off; nilo.csrf.sameOrigin covers both.
Secure on a development server is fine: browsers have treated
http://localhost as a secure context since 2020.
.none without .secure is refused, because every current browser drops that
combination and the symptom is a cookie that silently never arrives.
Two cookies are two cookies¶
Setting a header twice replaces it. Set-Cookie is the one exception — calling
setCookie twice sends two of them, because the spec says a server must, and
because the alternative is a login that silently delivers only its second
cookie.
Clearing one¶
try c.clearCookie(.{ .name = "session" });
A browser matches a deletion on the name, the path and the domain. A
cookie set under /admin is not cleared by a deletion at the default /, and
nothing anywhere tells you it was not — so pass the same ones you set it with:
try c.clearCookie(.{ .name = "session", .path = "/admin" });
A value with a ; in it is refused¶
try c.setCookie(.{ .name = "session", .value = "abc; Path=/admin" });
That is not a broken cookie — it is a cookie with a path nobody wrote, because
; separates attributes and the grammar has no escaping to defend with. So it
is refused, with a 500 saying which character and to encode the value first.
The same goes for a space, a comma, a quote, a backslash and any control byte.
Base64 and hex — which is what a session token normally is — pass untouched.
The signed-in user¶
Reading the cookie in every handler is not the shape to reach for. A resolved value reads it once and appears in an argument list by name:
const SignedIn = struct {
pub const nilo_resolve = authenticate;
email: []const u8,
};
fn authenticate(c: *nilo.Ctx, sessions: *Sessions, arena: std.mem.Allocator) !SignedIn {
const token = c.cookie("session") orelse
return fail.unauthorized("you are not signed in", .{});
...
}
fn me(user: SignedIn) !Profile { … } // and that is the whole wiring
Sessions¶
A session is the cookie's commonest job, and nilo has a shape for it:
Session(T) seals a struct of your own into one cookie,
encrypted and signed, with nothing kept on the server. Reach for that first —
it is a resolved value, so the handler asks for it by type and never reads the
cookie itself.
What Session(T) cannot do is be revoked early, because there is no row to go
and mark (Sessions). A session that has to
be cut short from the server side is a store of your own — a token in the
cookie and a table behind it — and that is an ordinary
Service. examples/forms
is that shape in about forty lines. Which of the two you want is the one
decision; nilo draws the same line around it that it draws around
authentication and takes no side.
Testing¶
The test client can ask what a response set:
const answer = try client.post(&app, "/sign-in", "");
try testing.expect(answer.setCookie("session") != null);
try testing.expectEqual(@as(usize, 2), answer.headerCount("Set-Cookie"));
and a request carries one the way any header does, through client.send:
POST /me HTTP/1.1\r\nHost: t\r\nCookie: session=abc123\r\n\r\n
See also¶
- ADR 029
— why nothing is decoded, why
Set-Cookiebreaks the replace rule, and why a bad value is refused rather than escaped.