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.
: 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: {
readonlyout:Outgoing;
readonlyparams: {};
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlycookies: {
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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>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
constsessions: {
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
constapp: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.
constenv: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
opensslrand-base6432
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:
constauth= hook.beforeParse((ctx) => {
const
constuserId:string|undefined
userId=signedCookie(ctx, "session");
if (!
constuserId:string|undefined
userId) thrownewHttpError(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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>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",
consttoken: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.