Skip to content

Authentication

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;
  • end(sessionId) forgets it.

A table, a Redis hash or a map in memory all fit.

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
:
const env: {
COOKIE_SECRET: string;
}
env
.
type COOKIE_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:

import { HttpError, hook, route, signedCookie } from "@tetsujs/core";
export const authenticate = (
sessions: Sessions
sessions
: Sessions) =>
hook.beforeParse(async (ctx) => {
const
const id: string | undefined
id
= signedCookie(ctx, "session");
const
const user: User | undefined
user
=
const id: string | undefined
id
=== undefined ? undefined : await
sessions: Sessions
sessions
.find(
const id: string
id
);
if (!
const user: User | undefined
user
) throw new HttpError(401);
return {
user: User
user
};
});
const signedIn = authenticate(
const sessions: Sessions
sessions
);
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: "/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: {
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;
}) => 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
) =>
const notes: 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.

A hook that reads the user declares it with Requires. Mounting it where nothing provides user is a compile error that names the missing field:

import type { Requires } from "@tetsujs/core";
import { HttpError, hook, httpError, route } from "@tetsujs/core";
import { z } from "zod";
const adminOnly = hook.beforeParse((
ctx: Requires<{
user: User;
}>
ctx
: Requires<{
user: User
user
: User }>) => {
if (!
ctx: Requires<{
user: User;
}>
ctx
.
user: User
user
.
admin: boolean
admin
) throw new HttpError(403);
});
const ownNote = hook.beforeHandle(
async (
ctx: Requires<{
user: User;
params: {
id: number;
};
}>
ctx
: Requires<{
user: User
user
: User;
params: {
id: number;
}
params
: {
id: number
id
: number } }>) => {
const
const note: Note | undefined
note
= await
const notes: NoteStore
notes
.find(
ctx: Requires<{
user: User;
params: {
id: number;
};
}>
ctx
.
user: User
user
.
id: string
id
,
ctx: Requires<{
user: User;
params: {
id: number;
};
}>
ctx
.
params: {
id: number;
}
params
.
id: number
id
);
if (!
const note: Note | undefined
note
) throw httpError(404, "NOTE_NOT_FOUND");
return {
note: Note
note
};
},
);
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: "/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.

path
: "/notes/:id",
schema: { params: z.object({ id: z.coerce.number() }) },
hooks: { beforeParse: [signedIn, adminOnly], beforeHandle: [ownNote] },
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: {
id: number;
};
user: User;
note: Note;
}) => 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: {
id: number;
};
user: User;
note: Note;
}
ctx
) => {
await
const notes: 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.

Signing in checks the credentials, starts a session and sets the cookie. Signing out ends the session and deletes the cookie:

import { controller, httpError, route, signedCookie } from "@tetsujs/core";
import { z } from "zod";
const Credentials = z.object({ email: z.email(), password: z.string().min(1) });
const
const cookie: {
readonly httpOnly: true;
readonly secure: true;
readonly sameSite: "lax";
readonly path: "/";
}
cookie
= {
httpOnly: true
httpOnly
: true,
secure: true
secure
: true,
sameSite: "lax"
sameSite
: "lax",
path: "/"
path
: "/" } as const;
export const sessionController = controller(
"Session",
({
accounts: Accounts
accounts
,
sessions: Sessions
sessions
}: {
accounts: Accounts
accounts
: Accounts;
sessions: Sessions
sessions
: Sessions }) => ({
signIn: 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",
schema: { body: Credentials,
response: {
readonly 204: null;
}
response
: { 204: null } },
handler: (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;
};
}) => 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
const user: 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 (!
const user: User | undefined
user
) throw httpError(401, "BAD_CREDENTIALS", "Wrong email or password");
const
const id: string
id
= await
sessions: Sessions
sessions
.start(
const user: 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",
const id: string
id
, { ...
const cookie: {
readonly httpOnly: true;
readonly secure: true;
readonly sameSite: "lax";
readonly path: "/";
}
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: {
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: {};
}) => 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
const id: 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 (
const id: string | undefined
id
!== undefined) await
sessions: Sessions
sessions
.end(
const id: 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",
const cookie: {
readonly httpOnly: true;
readonly secure: true;
readonly sameSite: "lax";
readonly path: "/";
}
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: {
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;
}) => {
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";
export const apiKey = (
keys: ApiKeys
keys
: ApiKeys) =>
hook.beforeParse(async (ctx) => {
const
const header: 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.

MDN Reference

headers
.get("authorization") ?? "";
const
const key: string
key
=
const header: string
header
.startsWith("Bearer ") ?
const header: string
header
.slice(7) : "";
const
const client: Client | undefined
client
=
const key: string
key
=== "" ? undefined : await
keys: ApiKeys
keys
.find(
const key: string
key
);
if (!
const client: Client | undefined
client
) throw new HttpError(401);
return {
client: Client
client
};
});

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
const trusted: Set<string>
trusted
= new Set(["https://app.example.com"]);
const
const safe: Set<string>
safe
= new Set(["GET", "HEAD", "OPTIONS"]);
const sameOrigin = hook.beforeParse((ctx) => {
if (
const safe: 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.

MDN Reference

method
)) return;
const
const origin: string | null
origin
= 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.

MDN Reference

headers
.get("origin");
if (!
const origin: string | null
origin
|| !
const trusted: Set<string>
trusted
.has(
const origin: string
origin
)) throw httpError(403, "ORIGIN_NOT_ALLOWED");
});

A client that is not a browser sends no Origin and is refused here; it should authenticate with a token instead of a cookie.

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.