This page builds sign-in for an API: a session in a signed cookie, a hook
that refuses an unknown caller and gives the routes behind it a typed
ctx.user, and the routes that start and end a session. Bearer tokens and
API keys follow the same pattern.
Tetsu has no authentication module and no session store. It provides the
pieces: signed cookies, hooks that add to the context, and Requires. The
session store is a service of your own. The examples assume one with three
methods:
start(userId) makes a new random session id and records its owner;
find(sessionId) returns the user, or undefined when the id is unknown
or expired;
With a secret, the cookies named in sign are signed on the way out and
verified on the way in:
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:
constenv: {
COOKIE_SECRET:string;
}
env.
typeCOOKIE_SECRET: string
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 });
Use 32 random bytes or more (openssl rand -base64 32), read from the
environment in main.ts. An empty or missing secret is refused at startup.
A signature proves the value came from this application, so a forged id is
refused without a lookup. It does not hide the value: the client can read
it, so the cookie carries a session id and nothing else. See
Cookies.
A hook in beforeParse reads the session, asks the store, and either
refuses with 401 or returns the user. What it returns joins the context,
typed, for everything that runs after 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: "/notes"
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: "/notes",
hooks: { beforeParse: [signedIn] },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
user:User;
}) =>Promise<Note[]>
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: {};
user: User;
}
ctx) =>
constnotes:NoteStore
notes.list(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}
ctx.user.
id: string
id),
user: User
});
beforeParse refuses before the body is read, so an upload from a
stranger costs nothing, and a rate limit mounted after the hook can count
by user.
signedCookie(), not ctx.cookies, because ctx.cookies is filled
only after the body is read. signedCookie() reads the cookie header,
checks the signature, and returns the value, or undefined when the
cookie is missing or forged.
The hook is a factory over the store. A controller builds it from the
sessions it is given — see
Hooks and dependencies.
: 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: "DELETE",
path: "/notes/:id"
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.
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: {
id: number;
};
user: User;
note: Note;
}
ctx) => {
await
constnotes:NoteStore
notes.remove(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: number;
};
user: User;
note: Note;
}
ctx.
note: Note
note.
id: number
id);
},
});
adminOnly must come after signedIn; the other order does not compile.
ownNote reads the validated params, so it goes in beforeHandle.
Mounted on a group, authenticate still refuses everyone under it, but
ctx.user is not typed in the handlers, so a route that reads it mounts the
hook itself; see
Context and its types.
: 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",
schema: { body: Credentials,
response: {
readonly 204: null;
}
response: { 204: null } },
handler: (ctx: {
readonlyout:Outgoing&DeclaredOutgoing<204>;
readonlyparams: {};
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlybody: {
email:string;
password:string;
};
}) =>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 out: Outgoing & DeclaredOutgoing<204>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
email: string;
password: string;
};
}
ctx) => {
const
constuser:User|undefined
user=await
accounts: Accounts
accounts.verify(
ctx: {
readonly out: Outgoing & DeclaredOutgoing<204>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
email: string;
password: string;
};
}
ctx.
body: {
email: string;
password: string;
}
body.
email: string
email,
ctx: {
readonly out: Outgoing & DeclaredOutgoing<204>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
email: string;
password: string;
};
}
ctx.
body: {
email: string;
password: string;
}
body.
password: string
password);
if (!
constuser:User|undefined
user) throwhttpError(401, "BAD_CREDENTIALS", "Wrong email or password");
const
constid:string
id=await
sessions: Sessions
sessions.start(
constuser:User
user.
id: string
id);
ctx: {
readonly out: Outgoing & DeclaredOutgoing<204>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
email: string;
password: string;
};
}
ctx.
out: Outgoing & DeclaredOutgoing<204>
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",
constid:string
id, { ...
constcookie: {
readonlyhttpOnly:true;
readonlysecure:true;
readonlysameSite:"lax";
readonlypath:"/";
}
cookie,
maxAge?: number |undefined
maxAge: 7*24*3600 });
ctx: {
readonly out: Outgoing & DeclaredOutgoing<204>;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly body: {
email: string;
password: string;
};
}
ctx.
out: Outgoing & DeclaredOutgoing<204>
Response parameters for the serialized handler result.
out.
status: 204|undefined
status=204;
},
}),
signOut: route({
method: "DELETE"
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: "DELETE",
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",
schema?: {
readonly response: {
readonly 204: null;
};
} |undefined
Validation schemas; an absent part is neither parsed nor typed.
schema: {
response: {
readonly 204: null;
}
response: { 204: null } },
handler: (ctx: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing&DeclaredOutgoing<204>;
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 & DeclaredOutgoing<204>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx) => {
const
constid:string|undefined
id=signedCookie(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<204>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx, "session");
if (
constid:string|undefined
id!==undefined) await
sessions: Sessions
sessions.end(
constid:string
id);
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<204>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx.
out: Outgoing & DeclaredOutgoing<204>
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("session",
constcookie: {
readonlyhttpOnly:true;
readonlysecure:true;
readonlysameSite:"lax";
readonlypath:"/";
}
cookie);
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing & DeclaredOutgoing<204>;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx.
out: Outgoing & DeclaredOutgoing<204>
Response parameters for the serialized handler result.
out.
status: 204|undefined
status=204;
},
}),
me: 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: [authenticate(
sessions: Sessions
sessions)] },
handler: (ctx: {
readonlyout:Outgoing;
readonlyparams: {};
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyroute:RouteInfo;
readonlystartedAt:number;
user:User;
}) => {
id: string;
name: 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 out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
user: User;
}
ctx) => ({
id: string
id:
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
user: User;
}
ctx.
user: User
user.
id: string
id,
name: string
name:
ctx: {
readonly out: Outgoing;
readonly params: {};
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly route: RouteInfo;
readonly startedAt: number;
user: User;
}
ctx.
user: User
user.
name: string
name }),
}),
}),
);
A new session id at every sign-in, so an id planted before sign-in is
worth nothing after it.
httpOnly keeps the cookie from the page’s scripts; secure
keeps it off plain HTTP. The value is signed without the route doing
anything, because session is in sign.
Deleting a cookie needs the path and domain it was set with.
Sharing cookie between the two routes keeps them the same.
Sign-out answers 204 either way, so signing out twice is not an
error.
A wrong password answers 401 with its own code, BAD_CREDENTIALS, which
a client can tell apart from an expired session’s UNAUTHORIZED. To test
the whole flow, use a client with a cookie jar — see
Testing.
A token in the authorization header is the same hook with another
source:
import { HttpError, hook } from"@tetsujs/core";
exportconstapiKey= (
keys: ApiKeys
keys:ApiKeys) =>
hook.beforeParse(async (ctx) => {
const
constheader:string
header= ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req.
headers: Headers
The headers read-only property of the with the request.
How the token is checked — a JWT library, a key looked up by its hash — is
the service’s business; the route sees only ctx.client. To accept either
a session or a token, write one hook that tries both and returns the same
field.
Wrap the hook in secured() from @tetsujs/openapi to put its scheme and
its 401 in the OpenAPI document. See
documenting hooks.
With the frontend on the same site, the lax cookie above needs nothing
more. Browsers send it on the site’s own requests and on navigation to it,
but not on a form post or a fetch from another site’s page, and that is
what stops those pages from acting as the user.
A frontend on another site needs the cookie set with secure: true and
sameSite: "none", and cors({ credentials: true }); see
Cookies. The browser
then sends the cookie on requests from any site. CORS stops another page
from reading the answer, not from sending the request: a form on another
site can still post to the API with the user’s cookie.
So with sameSite: "none", check the Origin of every request that
changes something. A hook after cors() refuses the ones not on the list:
const
consttrusted:Set<string>
trusted=newSet(["https://app.example.com"]);
const
constsafe:Set<string>
safe=newSet(["GET", "HEAD", "OPTIONS"]);
constsameOrigin= hook.beforeParse((ctx) => {
if (
constsafe:Set<string>
safe.has(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req.
method: string
The method read-only property of the POST, etc.) A String indicating the method of the request.
The raw incoming request, always available as an escape hatch.
On a request that matched a route, Bun's router delivers its own
request object carrying cookies — a Bun.CookieMap whose mutations
are applied to the response as Set-Cookie automatically, with Bun's
defaults (Path=/; SameSite=Lax). The field is optional because it
does not exist where Bun's router was not involved: the 404 fallback
and unit-tested handlers. ctx.out.headers.append("set-cookie", ...)
is the fallback that works everywhere.
req.
headers: Headers
The headers read-only property of the with the request.
Sign-in is where passwords are guessed. Limit it twice: by the client’s
address, before the body is read, and by the account being tried, after
validation, which stops guessing spread across many addresses. See
@tetsujs/rate-limit.
Behind authenticate, a limiter can count by user instead:
key: (ctx: Requires<{ user: User }>) => ctx.user.id, mounted after it in
beforeParse. Do not key by the raw cookie: a client that sends a new
value each time gets a new budget each time. Behind a proxy, the address
is the proxy’s until you say otherwise — see
Behind a proxy.
A WebSocket handshake is a request and goes through the same hooks.
authenticate refuses it with an ordinary 401, and the user it returns
is socket.data.user. See WebSockets.