Cookies
Hearth reads and writes cookies through a jar shared by the request. Register .cookies() on the router, then call cookies(cx) to read cookies or queue changes. When the handler returns, Hearth adds the changes to the response as Set-Cookie headers. This also works when the handler returns an error or redirect.
Cookies are part of the default feature set, and everything below is re-exported from hearth::cookie. Hearth builds on the cookie crate: a cookie is a Cookie, and signing and encryption use its Key.
use hearth::router::module_router;
use hearth::{
cookie::RouterBuilderCookieExt,
router::Router,
};
let router = module_router!()
.cookies()
.build();
Reading and writing
cookies(cx) returns the request's jar. Repeated calls share its contents, including pending changes. Import the Cookies trait to use its methods.
A cookie is a Cookie from the cookie crate. Build a bare one with Cookie::new, or use Cookie::build for attributes:
// src/app/api/theme.rs
use hearth::{
Result,
context::Cx,
cookie::{Cookie, Cookies, cookies},
router::route,
};
#[route(POST)]
async fn toggle_theme(cx: &Cx) -> Result<String> {
let jar = cookies(cx);
let next = match jar.get("theme") {
Some(theme) if theme.value() == "dark" => "light",
_ => "dark",
};
jar.add(Cookie::build(("theme", next)).path("/").build());
Ok(next.to_owned())
}
get(name)returns the current cookie, including pending changes, orNoneif it is absent.add(cookie)queues aSet-Cookie.remove(cookie)queues an expiring removal cookie. Pass the samePath/Domainthe cookie was set with so the browser matches and clears it:
jar.remove(Cookie::build(("session", "")).path("/").build());
add and remove accept anything that implements Into<Cookie>, so a plain (name, value) tuple works too when you don't need attributes:
jar.add(("theme", "dark"));
Writes must happen before the response
Set cookies before the handler returns. Later work, such as a streaming response, cannot change the response headers. Adding or removing a cookie after the jar has been sealed panics. Reading cookies still works.
// src/app/report.rs
use hearth::{
Result,
context::Cx,
cookie::{Cookies, cookies},
router::page,
view::{View, suspense, view},
};
#[page]
async fn report(cx: &Cx) -> Result<impl View> {
// Runs while the handler is still in charge of the response.
cookies(cx).add(("last_report", "sales"));
Ok(view! {
suspense(
fallback: view! { <p>"Loading..."</p> },
// Streams in later, so it must not touch the jar.
figures()
)
})
}
Building cookies with cookie!
For cookies with several attributes, the cookie! macro is more compact than the builder. It mirrors the Set-Cookie header: the name = value pair first, then any number of ;-separated attributes.
use hearth::cookie::{Cookie, SameSite, cookie, time::Duration};
let plain: Cookie = cookie!("theme" = "dark");
let session: Cookie = cookie! {
"session" = "abc123";
Path = "/";
Secure;
HttpOnly;
SameSite = Lax;
MaxAge = Duration::hours(1)
};
Default and override attributes
Use the Cookies methods to apply shared attributes to cookies written through a jar:
default_*fills the attribute only when the cookie does not already set it.override_*forces the attribute, replacing any value the cookie had.
use hearth::cookie::{Cookies, SameSite, cookie, cookies};
let jar = cookies(cx)
.default_secure(true)
.default_http_only(true)
.default_same_site(SameSite::Lax)
.default_path("/");
// Picks up Secure, HttpOnly, SameSite=Lax, Path=/ from the defaults above.
jar.add(cookie!("session" = "abc123"));
Build the configured jar once and reuse it for several writes. See Cookies for the available attributes.
Put the configuration in an application helper so callers use consistent defaults:
use hearth::{
context::Cx,
cookie::{Cookies, SameSite},
};
/// The application cookie jar, with our security defaults applied.
fn cookies(cx: &Cx) -> impl Cookies {
hearth::cookie::cookies(cx)
.default_secure(true)
.default_http_only(true)
.default_same_site(SameSite::Lax)
.default_path("/")
}
Callers use this helper whenever they need the configured jar. A helper can also return a signed or private jar.
Use map to apply a custom change to each cookie:
let jar = cookies(cx).map(|cookie| cookie.set_partitioned(true));
Name prefixes
RFC 6265bis cookie name prefixes ask the browser to enforce extra constraints based on the cookie's name. Hearth applies the prefix and its required attributes for you, and strips the prefix back off on read so your code keeps using the bare name.
prefix_host(__Host-): the cookie must beSecure, havePath=/, and carry noDomain. The tightest scoping: bound to the exact host, unavailable to subdomains.prefix_secure(__Secure-): the cookie must beSecure.
use hearth::cookie::{Cookies, cookie, cookies};
let jar = cookies(cx).override_prefix_host();
// Stored as `__Host-session`, forced Secure + Path=/, no Domain.
jar.add(cookie!("session" = "abc123"));
// Looked up under the prefixed name; returns it with the prefix stripped.
let session = jar.get("session");
As with attributes, each prefix has a default_* form that fills the required attributes only when unset, and an override_* form that forces them for guaranteed RFC compliance. Use override_* unless you have a reason to let a caller's value stand.
Signed cookies
A signed cookie lets the server detect changes to its value. The client can still read it. Wrap the jar with a Key to sign writes and verify reads. A missing cookie or invalid signature returns None.
use hearth::cookie::{Cookies, Key, cookie, cookies};
let key = Key::generate();
let jar = cookies(cx).signed(&key);
jar.add(cookie!("user_id" = "42"));
// Returns the cookie only if the signature checks out.
let user_id = jar.get("user_id");
Private cookies
A private cookie encrypts and authenticates its value. The client cannot read the plaintext or change it without detection. Read and write it with the same name, key, and jar configuration.
use hearth::cookie::{Cookies, Key, cookie, cookies};
let key = Key::generate();
let jar = cookies(cx).private(&key);
jar.add(cookie!("session" = "secret-token"));
let session = jar.get("session"); // None if missing or it fails to decrypt
Signed and private jars can be combined with prefixes and attribute defaults.
Keys from app context
Share a Key across requests by registering it as app context:
use hearth::{
cookie::{Key, RouterBuilderCookieExt},
router::{Router, RouterBuilderDiscoverExt, module_router},
};
pub fn router() -> Router {
module_router!()
.discover()
.cookies()
.app_context(Key::generate())
.build()
}
Then signed_cookies(cx) and private_cookies(cx) use the registered key:
// src/app/api/login.rs
use hearth::{
Result,
context::Cx,
cookie::{Cookies, cookie, private_cookies},
router::route,
};
#[route(POST)]
async fn login(cx: &Cx) -> Result<&'static str> {
private_cookies(cx).add(cookie!("session" = "secret-token"; Path = "/"));
Ok("logged in")
}
Both functions panic if no Key was registered. The example generates a new key at startup. To keep existing cookies valid across restarts and server instances, load the same persisted key instead.
Typed cookie stores
A CookieStore<T> stores a typed value as JSON in one cookie. The type must implement Serialize and DeserializeOwned.
Create a store with cookie_store and the jar it should use. The store inherits that jar's configuration. For example, a store built on private_cookies encrypts its value.
// src/app/api/cart.rs
use serde::{Deserialize, Serialize};
use hearth::{
Result,
context::Cx,
cookie::{cookie_store, private_cookies},
router::route,
};
#[derive(Default, Serialize, Deserialize)]
struct Cart {
items: Vec<String>,
}
#[route(POST)]
async fn add_item(cx: &Cx) -> Result<String> {
let cart = cookie_store::<Cart, _>(private_cookies(cx), "cart")
.parse_or_default()
.update(|cart| cart.items.push("widget".to_owned()))
.commit()?;
Ok(format!("{} items in cart", cart.items.len()))
}
Reading the incoming value
cookie_store returns an UnparsedCookieStore. Choose how to handle a missing or malformed cookie when you parse it:
parsereturnsOk(None)when the cookie is absent andErrwhen it is present but won't deserialize, so you can distinguish the two.parse_or(value)falls back tovaluewhen the cookie is absent or malformed.parse_or_else(f)falls back tof().parse_or_default()falls back toT::default().
The fallback methods treat malformed and missing cookies alike. This can reset cookies that no longer match your data format. Use parse when you need to distinguish these cases.
Once parsed, you hold a CookieStore<T> whose value is known, so reads and mutations no longer return [Result]:
readborrows the value;getclones it (whenT: Clone).set(value)replaces the value andupdate(f)mutates it in place. Both return the store so calls can be chained.
Nothing is written until commit
Reads and mutations touch only the in-memory value. No Set-Cookie is queued until you call commit, which serializes the value, writes it through the jar, and hands the value back:
let cart = cookie_store::<Cart, _>(private_cookies(cx), "cart")
.parse_or_default()
.update(|cart| cart.items.push("widget".to_owned()))
.commit()?;
Dropping the store without committing (or calling rollback to say so explicitly) discards the pending changes. This makes it easy to update a cookie only once some other work has succeeded: do the work first, and commit last.
To overwrite a cookie without reading its current contents, set on the unparsed store skips the parse step entirely:
cookie_store::<Cart, _>(private_cookies(cx), "cart")
.set(Cart::default())
.commit()?;
To delete a cookie, remove queues an expiring removal. It's available both on a parsed store (CookieStore::remove) and directly on the unparsed one (UnparsedCookieStore::remove) when you just want to clear the cookie without reading it, for example on logout:
cookie_store::<Cart, _>(private_cookies(cx), "cart").remove();
Use the same jar configuration for removal as for writing. The browser needs matching Path, Domain, and name attributes to clear the cookie.
A helper per store
As with the jar combinators, the idiomatic pattern is to wrap each store in a small helper so its name and backing jar stay consistent everywhere it's used:
use hearth::{
context::Cx,
cookie::{CookieStore, Cookies, cookie_store, signed_cookies},
};
fn cart(cx: &Cx) -> CookieStore<Cart, impl Cookies> {
cookie_store(signed_cookies(cx), "cart").parse_or_default()
}
Every handler then calls cart(cx), mutates, and commits, without repeating the name or the jar configuration.