Skip to content

Cookies

Cookies arrive as a request part and leave on ctx.out. With a secret, the application signs the cookies it names on the way out and verifies them on the way in, without any call site mentioning it.

schema.cookies validates incoming cookies and adds ctx.cookies, typed by the schema’s output:

const Prefs = z.object({ theme: z.enum(["light", "dark"]).default("light") });
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/prefs"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/prefs",
schema: { cookies: Prefs },
handler: (ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly cookies: {
theme: "light" | "dark";
};
}) => {
theme: "light" | "dark";
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly cookies: {
theme: "light" | "dark";
};
}
ctx
) => ({ theme:
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly cookies: {
theme: "light" | "dark";
};
}
ctx
.
cookies: {
theme: "light" | "dark";
}
cookies
.theme }),
theme: "light" | "dark"
});

Cookies are a record of strings and are validated like query: in the same phase, with the same 422. A required cookie that is missing is reported at ["cookies", "theme"]. A name sent twice reads as its first value, which is the host’s own cookie rather than one a sibling subdomain set.

Without a schema there is no ctx.cookies. Bun’s ctx.req.cookies holds the values as the client sent them, unchecked.

Outgoing cookies are written by name on ctx.out.cookies:

route({
method: "POST"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "POST",
path: "/session"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/session",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Promise<void>

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: async (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => {
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing

Response parameters for the serialized handler result.

out
.
cookies: ResponseCookies

Cookies the response will carry, written by name rather than by serializing a set-cookie value by hand.

The mirror of

Outgoing.headers

, and it stands to ctx.cookies exactly as ctx.out.headers stands to ctx.headers: what arrived on one side, what leaves on the other.

cookies
.set("session", await
const sessions: {
open(): Promise<string>;
}
sessions
.open(), {
httpOnly?: boolean | undefined
httpOnly
: true,
maxAge?: number | undefined
maxAge
: 3600 });
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing

Response parameters for the serialized handler result.

out
.
cookies: ResponseCookies

Cookies the response will carry, written by name rather than by serializing a set-cookie value by hand.

The mirror of

Outgoing.headers

, and it stands to ctx.cookies exactly as ctx.out.headers stands to ctx.headers: what arrived on one side, what leaves on the other.

cookies
.delete("flash");
},
});

The attributes are Bun’s CookieInit without the name and value: path, domain, maxAge, expires, httpOnly, secure, sameSite, partitioned. Left out, Bun’s defaults apply: Path=/ and SameSite=Lax.

  • Writing a name twice replaces it. Different names accumulate.
  • Deleting needs the same path and domain the cookie was set with. The browser treats a different path as a different cookie.
  • Cookies go out with every response, including a redirect, an error and a hook’s short-circuit.

Changes to Bun’s ctx.req.cookies are sent too, but only ctx.out.cookies signs, and only it works on every response, including the 404 fallback and unit tests.

Give the application a secret, and the cookies it names are signed on the way out and verified on the way in:

const
const app: App<RoutesOf<object[]>>
app
= createApp({
cookies?: CookieOptions | undefined

How cookies are signed, when they are.

Configuration rather than schema: a secret is not a shape, so it has no place in a route's schema.cookies, and the policy is the application's rather than any one endpoint's. A covered cookie is sealed on the way out and verified on the way in without a call site mentioning it, which is the point — a signature nobody can forget to apply.

cookies
: {
secret: string

The key the signature is derived from — 32 random bytes or more.

Only ever used through HMAC-SHA256; it is not an encryption key, and a signed cookie's value is still readable by the client. Signing answers "did this value come from us", not "can this be seen".

An empty or missing secret is refused at startup: with it, anyone can compute the signature, and a forged cookie would read as ours.

secret
: Bun.
const env: Bun.Env & NodeJS.ProcessEnv & ImportMetaEnv

The environment variables of the process

Defaults to process.env as it was when the current Bun process launched.

Changes to process.env at runtime won't automatically be reflected in the default value. For that, you can pass process.env explicitly.

env
.COOKIE_SECRET!,
sign?: string | true | readonly string[] | undefined

Which cookies are signed. true covers every one of them.

A list is the safer default of the two: sealing everything means a cookie set by anything other than this application — an analytics script, a proxy — fails verification and reads as absent, which is a confusing way to discover the setting.

sign
: ["session"] },
routes: object[]

The topology: a group, a controller, or an array of either.

routes
,
});
cookies
secret the key, 32 random bytes or more
sign a name, a list of names, or true for every cookie; true when omitted

A signed cookie is sent as value.signature, an HMAC-SHA256 of the value. ctx.out.cookies.set("session", "42") sends session=42.DESNm6lp…, and ctx.cookies.session reads "42" back.

Generate the secret once and keep it with the application’s other secrets:

Terminal window
openssl rand -base64 32

An empty or missing secret is refused at startup with a TypeError.

Prefer a list of names over true. With true, a cookie set by anything else, such as an analytics script or a proxy, fails verification and reads as absent.

Signing proves the value came from the application. It does not hide it: the client can read a signed cookie, so it should hold an identifier, not a secret.

A cookie whose signature does not hold is left out. schema.cookies then reports it missing, so a forger cannot tell a bad signature from a cookie that was never sent.

Cookies a hook returns are checked the same way. A hook that takes a mobile client’s session from a header returns it signed, exactly as the client sent it; a plain session: "admin" is dropped. Cookies passed on unchanged from ctx.cookies are already verified and stay.

ctx.cookies is filled at validation, after the body is read. A hook that authenticates earlier, to refuse before the body is read or to give a rate limit a user to count, uses signedCookie(). It checks the signature and returns the value without it:

const auth = hook.beforeParse((ctx) => {
const
const userId: string | undefined
userId
= signedCookie(ctx, "session");
if (!
const userId: string | undefined
userId
) throw new HttpError(401);
return {
userId: string
userId
};
});
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/me"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/me",
hooks: { beforeParse: [auth] },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
userId: string;
}) => {
id: string;
}

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
userId: string;
}
ctx
) => ({
id: string
id
:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
userId: string;
}
ctx
.
userId: string
userId
}),
});
  • It reads the request’s cookie header, not what a hook put on the context.
  • It returns undefined when the cookie is missing or its signature does not hold. Of a name sent twice, it takes the first value that verifies.
  • It throws for a name the application does not sign: that is a bug in the code, not in the request.

This is also how a missing session answers 401. A session required by schema.cookies fails validation with 422 instead, after the body was read.

A frontend on another site that sends the session with cors({ credentials: true }) needs the cookie set with secure: true and sameSite: "none":

route({
method: "POST"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "POST",
path: "/session"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/session",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => void

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => {
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing

Response parameters for the serialized handler result.

out
.
cookies: ResponseCookies

Cookies the response will carry, written by name rather than by serializing a set-cookie value by hand.

The mirror of

Outgoing.headers

, and it stands to ctx.cookies exactly as ctx.out.headers stands to ctx.headers: what arrived on one side, what leaves on the other.

cookies
.set("session",
const token: string
token
, {
httpOnly?: boolean | undefined
httpOnly
: true,
secure?: boolean | undefined
secure
: true,
sameSite?: Bun.CookieSameSite | undefined

Defaults to lax.

sameSite
: "none" });
},
});

The default, Lax, keeps the cookie off cross-site requests, and browsers refuse none without secure. See @tetsujs/cors for the other half. A cookie sent from any site also needs a check against cross-site requests; see Authentication.

A handler called with testCtx() has no application behind it, and so no secret: pass the application’s cookie options as its second argument. A client from serve(app).client() keeps a cookie jar that holds a signed cookie as it arrived. Testing shows both.