Browse documentation
Documentation

Cookies

Edit this guide on GitHub

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())
}
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()
        )
    })
}

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:

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.

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.

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:

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]:

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.