[Back-end]
Backend Starter
A production-ready Express backend starter with Bun, TypeScript, PostgreSQL, Prisma, and secure JWT auth with rotating refresh sessions.
This is a clean, production-ready backend starter using Express, TypeScript, and PostgreSQL. It covers the essentials: project setup, environment validation, database with Prisma, modern JWT authentication with session rotation, and secure defaults.
Tech stack
- Runtime/package manager: Bun
- Server: Express 5 + TypeScript
- Database: PostgreSQL 16 (Docker)
- ORM + migrations: Prisma
- Validation: Zod
- Auth:
joseJWT + refresh-session rotation in DB - Security: Helmet, CORS allowlist, rate limiting, HTTP-only cookies
- Logging: Pino
- Testing: Vitest + Supertest
1) Bootstrap project
mkdir express-ts-pg-backend && cd express-ts-pg-backend
bun init -y
bun add express zod cors helmet cookie-parser express-rate-limit pino pino-http argon2 jose dotenv @prisma/client
bun add -d typescript tsx prisma @types/node @types/express @types/cookie-parser vitest supertest @types/supertest
bunx tsc --init --rootDir src --outDir dist --module nodenext --moduleResolution nodenext --target ES2022
package.json scripts:
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"test": "vitest run",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate"
}
}
Suggested structure:
src/
app.ts
server.ts
config/
env.ts
lib/
prisma.ts
logger.ts
tokens.ts
features/
auth/
routes.ts
schema.ts
middlewares/
error-handler.ts
not-found.ts
2) Environment validation (non-negotiable)
Create .env.example:
NODE_ENV=development
PORT=4000
CORS_ORIGIN=http://localhost:3000
DATABASE_URL=postgresql://app_user:app_password@localhost:5432/app_db?schema=public
JWT_ACCESS_SECRET=replace-with-64-char-secret
JWT_REFRESH_SECRET=replace-with-64-char-secret
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=30d
REFRESH_COOKIE_NAME=rt
src/config/env.ts:
import 'dotenv/config';
import { import zz } from 'zod';
const const envSchema: z.ZodObject<{
NODE_ENV: z.ZodDefault<z.ZodEnum<{
development: "development";
test: "test";
production: "production";
}>>;
PORT: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
CORS_ORIGIN: z.ZodString;
DATABASE_URL: z.ZodString;
JWT_ACCESS_SECRET: z.ZodString;
JWT_REFRESH_SECRET: z.ZodString;
ACCESS_TOKEN_TTL: z.ZodDefault<z.ZodString>;
REFRESH_TOKEN_TTL: z.ZodDefault<z.ZodString>;
REFRESH_COOKIE_NAME: z.ZodDefault<z.ZodString>;
}, z.core.$strip>
envSchema = import zz.function object<{
NODE_ENV: z.ZodDefault<z.ZodEnum<{
development: "development";
test: "test";
production: "production";
}>>;
PORT: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
CORS_ORIGIN: z.ZodString;
DATABASE_URL: z.ZodString;
JWT_ACCESS_SECRET: z.ZodString;
JWT_REFRESH_SECRET: z.ZodString;
ACCESS_TOKEN_TTL: z.ZodDefault<z.ZodString>;
REFRESH_TOKEN_TTL: z.ZodDefault<z.ZodString>;
REFRESH_COOKIE_NAME: z.ZodDefault<z.ZodString>;
}>(shape?: {
NODE_ENV: z.ZodDefault<z.ZodEnum<{
development: "development";
test: "test";
production: "production";
}>>;
PORT: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
CORS_ORIGIN: z.ZodString;
DATABASE_URL: z.ZodString;
JWT_ACCESS_SECRET: z.ZodString;
JWT_REFRESH_SECRET: z.ZodString;
ACCESS_TOKEN_TTL: z.ZodDefault<z.ZodString>;
REFRESH_TOKEN_TTL: z.ZodDefault<z.ZodString>;
REFRESH_COOKIE_NAME: z.ZodDefault<z.ZodString>;
} | undefined, params?: string | ... 1 more ... | undefined): z.ZodObject<...>
object({
type NODE_ENV: z.ZodDefault<z.ZodEnum<{
development: "development";
test: "test";
production: "production";
}>>
NODE_ENV: import zz.enum<readonly ["development", "test", "production"]>(values: readonly ["development", "test", "production"], params?: string | z.core.$ZodEnumParams): z.ZodEnum<{
development: "development";
test: "test";
production: "production";
}> (+1 overload)
export enum
enum(['development', 'test', 'production']).ZodType<any, any, $ZodEnumInternals<{ development: "development"; test: "test"; production: "production"; }>>.default(def: "development" | "test" | "production"): z.ZodDefault<z.ZodEnum<{
development: "development";
test: "test";
production: "production";
}>> (+1 overload)
default('development'),
type PORT: z.ZodDefault<z.ZodCoercedNumber<unknown>>PORT: import zz.import coercecoerce.function number<unknown>(params?: string | z.core.$ZodNumberParams): z.ZodCoercedNumber<unknown>number()._ZodNumber<$ZodNumberInternals<unknown>>.int(params?: string | z.core.$ZodCheckNumberFormatParams): z.ZodCoercedNumber<unknown>Consider `z.int()` instead. This API is considered *legacy*; it will never be removed but a better alternative exists.int()._ZodNumber<$ZodNumberInternals<unknown>>.min(value: number, params?: string | z.core.$ZodCheckGreaterThanParams): z.ZodCoercedNumber<unknown>min(1)._ZodNumber<$ZodNumberInternals<unknown>>.max(value: number, params?: string | z.core.$ZodCheckLessThanParams): z.ZodCoercedNumber<unknown>max(65535).ZodType<any, any, $ZodNumberInternals<unknown>>.default(def: number): z.ZodDefault<z.ZodCoercedNumber<unknown>> (+1 overload)default(4000),
type CORS_ORIGIN: z.ZodStringCORS_ORIGIN: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(1),
type DATABASE_URL: z.ZodStringDATABASE_URL: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodString.url(params?: string | z.core.$ZodCheckURLParams): z.ZodStringurl(),
type JWT_ACCESS_SECRET: z.ZodStringJWT_ACCESS_SECRET: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(32),
type JWT_REFRESH_SECRET: z.ZodStringJWT_REFRESH_SECRET: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(32),
type ACCESS_TOKEN_TTL: z.ZodDefault<z.ZodString>ACCESS_TOKEN_TTL: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodType<any, any, $ZodStringInternals<string>>.default(def: string): z.ZodDefault<z.ZodString> (+1 overload)default('15m'),
type REFRESH_TOKEN_TTL: z.ZodDefault<z.ZodString>REFRESH_TOKEN_TTL: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodType<any, any, $ZodStringInternals<string>>.default(def: string): z.ZodDefault<z.ZodString> (+1 overload)default('30d'),
type REFRESH_COOKIE_NAME: z.ZodDefault<z.ZodString>REFRESH_COOKIE_NAME: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodType<any, any, $ZodStringInternals<string>>.default(def: string): z.ZodDefault<z.ZodString> (+1 overload)default('rt'),
});
export const const env: {
NODE_ENV: "development" | "test" | "production";
PORT: number;
CORS_ORIGIN: string;
DATABASE_URL: string;
JWT_ACCESS_SECRET: string;
JWT_REFRESH_SECRET: string;
ACCESS_TOKEN_TTL: string;
REFRESH_TOKEN_TTL: string;
REFRESH_COOKIE_NAME: string;
}
env = const envSchema: z.ZodObject<{
NODE_ENV: z.ZodDefault<z.ZodEnum<{
development: "development";
test: "test";
production: "production";
}>>;
PORT: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
CORS_ORIGIN: z.ZodString;
DATABASE_URL: z.ZodString;
JWT_ACCESS_SECRET: z.ZodString;
JWT_REFRESH_SECRET: z.ZodString;
ACCESS_TOKEN_TTL: z.ZodDefault<z.ZodString>;
REFRESH_TOKEN_TTL: z.ZodDefault<z.ZodString>;
REFRESH_COOKIE_NAME: z.ZodDefault<z.ZodString>;
}, z.core.$strip>
envSchema.ZodType<any, any, $ZodObjectInternals<{ NODE_ENV: ZodDefault<ZodEnum<{ development: "development"; test: "test"; production: "production"; }>>; PORT: ZodDefault<ZodCoercedNumber<unknown>>; ... 6 more ...; REFRESH_COOKIE_NAME: ZodDefault<...>; }, $strip>>.parse(data: unknown, params?: z.core.ParseContext<z.core.$ZodIssue>): {
NODE_ENV: "development" | "test" | "production";
PORT: number;
CORS_ORIGIN: string;
DATABASE_URL: string;
JWT_ACCESS_SECRET: string;
JWT_REFRESH_SECRET: string;
ACCESS_TOKEN_TTL: string;
REFRESH_TOKEN_TTL: string;
REFRESH_COOKIE_NAME: string;
}
parse(var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env);
If env is invalid, the app fails fast on boot.
3) PostgreSQL with Docker Compose
docker-compose.yml:
services:
db:
image: postgres:16-alpine
container_name: express-ts-pg-db
restart: unless-stopped
environment:
POSTGRES_DB: app_db
POSTGRES_USER: app_user
POSTGRES_PASSWORD: app_password
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app_user -d app_db']
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
Run DB:
docker compose up -d
4) Prisma schema + migrations
Initialize Prisma:
bunx prisma init --datasource-provider postgresql
prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum UserRole {
USER
ADMIN
}
model User {
id String @id @default(uuid())
fullName String
email String @unique
passwordHash String
role UserRole @default(USER)
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Session {
id String @id @default(cuid())
userId String
jti String @unique
userAgent String?
ip String?
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt])
@@index([expiresAt])
}
Apply migration:
bunx prisma migrate dev --name init_auth
src/lib/prisma.ts:
import { class PrismaClient<ClientOptions extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions, const U = "log" extends keyof ClientOptions ? ClientOptions["log"] extends (Prisma.LogLevel | Prisma.LogDefinition)[] ? Prisma.GetEvents<...> : never : never, ExtArgs extends $Extensions.InternalArgs = runtime.DefaultArgs>## Prisma Client ʲˢ
Type-safe database client for TypeScript & Node.jsPrismaClient } from '@prisma/client';
export const const prisma: PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>prisma = new new PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>(optionsArg?: Prisma.Subset<Prisma.PrismaClientOptions, Prisma.PrismaClientOptions> | undefined): PrismaClient<Prisma.PrismaClientOptions, never, DefaultArgs>## Prisma Client ʲˢ
Type-safe database client for TypeScript & Node.jsPrismaClient();
5) App foundation (Express 5)
src/lib/logger.ts:
import import pinopino from 'pino';
export const const logger: anylogger = import pinopino({
level: stringlevel: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedNODE_ENV === 'production' ? 'info' : 'debug',
});
src/app.ts:
import import expressexpress from 'express';
import import helmethelmet from 'helmet';
import import corscors from 'cors';
import import cookieParsercookieParser from 'cookie-parser';
import const rateLimit: (passedOptions?: Partial<Options>) => RateLimitRequestHandler
Create an instance of IP rate-limiting middleware for Express.rateLimit from 'express-rate-limit';
import import pinoHttppinoHttp from 'pino-http';
import { import envenv } from './config/env';
import { import loggerlogger } from './lib/logger';
import { import authRouterauthRouter } from './features/auth/routes';
import { import notFoundHandlernotFoundHandler } from './middlewares/not-found';
import { import errorHandlererrorHandler } from './middlewares/error-handler';
const const app: anyapp = import expressexpress();
const const originList: anyoriginList = import envenv.CORS_ORIGIN.split(',').map((s: anys) => s: anys.trim());
const app: anyapp.use(
import pinoHttppinoHttp({
logger: anylogger,
redact: string[]redact: ['req.headers.authorization', 'req.headers.cookie'],
}),
);
const app: anyapp.use(import helmethelmet());
const app: anyapp.use(
import corscors({
origin: (origin: any, cb: any) => anyorigin: (origin: anyorigin, cb: anycb) => {
if (!origin: anyorigin || const originList: anyoriginList.includes(origin: anyorigin)) return cb: anycb(null, true);
return cb: anycb(new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('CORS blocked'));
},
credentials: booleancredentials: true,
}),
);
const app: anyapp.use(import expressexpress.json({ limit: stringlimit: '1mb' }));
const app: anyapp.use(import cookieParsercookieParser());
const app: anyapp.use(
function rateLimit(passedOptions?: Partial<Options>): RateLimitRequestHandler
Create an instance of IP rate-limiting middleware for Express.rateLimit({
windowMs?: number | undefinedHow long we should remember the requests.
Defaults to `60000` ms (= 1 minute).windowMs: 60_000,
max?: number | ValueDeterminingMiddleware<number> | undefinedThe maximum number of connections to allow during the `window` before
rate limiting the client.
Can be the limit itself as a number or express middleware that parses
the request and then figures out the limit.max: 120,
standardHeaders?: boolean | "draft-6" | "draft-7" | "draft-8" | undefinedWhether to enable support for the standardized rate limit headers (`RateLimit-*`).
Defaults to `false` (for backward compatibility, but its use is recommended).standardHeaders: true,
legacyHeaders?: boolean | undefinedWhether to send `X-RateLimit-*` headers with the rate limit and the number
of requests.
Defaults to `true` (for backward compatibility).legacyHeaders: false,
}),
);
const app: anyapp.get('/health', (_req: any_req, res: anyres) => {
res: anyres.status(200).json({ ok: booleanok: true });
});
const app: anyapp.use('/v1/auth', import authRouterauthRouter);
const app: anyapp.use(import notFoundHandlernotFoundHandler);
const app: anyapp.use(import errorHandlererrorHandler);
export { const app: any
export app
app };
src/server.ts:
import { import appapp } from './app';
import { import envenv } from './config/env';
import { import loggerlogger } from './lib/logger';
import appapp.listen(import envenv.PORT, () => {
import loggerlogger.info(`API listening on http://localhost:${import envenv.PORT}`);
});
src/middlewares/error-handler.ts:
import type { import NextFunctionNextFunction, import RequestRequest, import ResponseResponse } from 'express';
export function function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction): voiderrorHandler(err: unknownerr: unknown, _req: Request_req: import RequestRequest, res: Responseres: import ResponseResponse, _next: NextFunction_next: import NextFunctionNextFunction) {
const const message: stringmessage = err: unknownerr instanceof var Error: ErrorConstructorError ? err: Errorerr.Error.message: stringmessage : 'Internal server error';
res: Responseres.status(500).json({ message: stringmessage });
}
src/middlewares/not-found.ts:
import type { import RequestRequest, import ResponseResponse } from 'express';
export function function notFoundHandler(_req: Request, res: Response): voidnotFoundHandler(_req: Request_req: import RequestRequest, res: Responseres: import ResponseResponse) {
res: Responseres.status(404).json({ message: stringmessage: 'Route not found' });
}
6) Modern JWT helpers with jose
src/lib/tokens.ts:
import { JWTPayload, class SignJWTThe SignJWT class is used to build and sign Compact JWS formatted JSON Web Tokens.
This class is exported (as a named export) from the main `'jose'` module entry point as well as
from its subpath export `'jose/jwt/sign'`.SignJWT, function jwtVerify<PayloadType = JWTPayload>(jwt: string | Uint8Array, key: CryptoKey | KeyObject | JWK | Uint8Array, options?: JWTVerifyOptions): Promise<JWTVerifyResult<PayloadType>> (+1 overload)Verifies the JWT format (to be a JWS Compact format), verifies the JWS signature, validates the
JWT Claims Set.
This function is exported (as a named export) from the main `'jose'` module entry point as well
as from its subpath export `'jose/jwt/verify'`.jwtVerify } from 'jose';
import { import envenv } from '../config/env';
const const accessSecret: Uint8Array<ArrayBuffer>accessSecret = new var TextEncoder: new () => TextEncoderThe **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder)TextEncoder().TextEncoder.encode(input?: string): Uint8Array<ArrayBuffer>The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode)encode(import envenv.JWT_ACCESS_SECRET);
const const refreshSecret: Uint8Array<ArrayBuffer>refreshSecret = new var TextEncoder: new () => TextEncoderThe **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder)TextEncoder().TextEncoder.encode(input?: string): Uint8Array<ArrayBuffer>The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode)encode(import envenv.JWT_REFRESH_SECRET);
export async function function signAccessToken(payload: JWTPayload): Promise<string>signAccessToken(payload: JWTPayloadpayload: JWTPayload) {
return new new SignJWT(payload?: JWTPayload): SignJWT
{@link
SignJWT
}
constructorSignJWT(payload: JWTPayloadpayload)
.SignJWT.setProtectedHeader(protectedHeader: JWTHeaderParameters): SignJWTSets the JWS Protected Header on the SignJWT object.setProtectedHeader({ CompactJWSHeaderParameters.alg: stringJWS "alg" (Algorithm) Header Parameteralg: 'HS256' })
.SignJWT.setIssuedAt(input?: number | string | Date): SignJWTSet the "iat" (Issued At) Claim.
- If no argument is used the current unix timestamp is used as the claim.
- If a `number` is passed as an argument it is used as the claim directly.
- If a `Date` instance is passed as an argument it is converted to unix timestamp and used as the
claim.
- If a `string` is passed as an argument it is resolved to a time span, and then added to the
current unix timestamp and used as the claim.
Format used for time span should be a number followed by a unit, such as "5 minutes" or "1
day".
Valid units are: "sec", "secs", "second", "seconds", "s", "minute", "minutes", "min", "mins",
"m", "hour", "hours", "hr", "hrs", "h", "day", "days", "d", "week", "weeks", "w", "year",
"years", "yr", "yrs", and "y". It is not possible to specify months. 365.25 days is used as an
alias for a year.
If the string is suffixed with "ago", or prefixed with a "-", the resulting time span gets
subtracted from the current unix timestamp. A "from now" suffix can also be used for
readability when adding to the current unix timestamp.setIssuedAt()
.SignJWT.setExpirationTime(input: number | string | Date): SignJWTSet the "exp" (Expiration Time) Claim.
- If a `number` is passed as an argument it is used as the claim directly.
- If a `Date` instance is passed as an argument it is converted to unix timestamp and used as the
claim.
- If a `string` is passed as an argument it is resolved to a time span, and then added to the
current unix timestamp and used as the claim.
Format used for time span should be a number followed by a unit, such as "5 minutes" or "1
day".
Valid units are: "sec", "secs", "second", "seconds", "s", "minute", "minutes", "min", "mins",
"m", "hour", "hours", "hr", "hrs", "h", "day", "days", "d", "week", "weeks", "w", "year",
"years", "yr", "yrs", and "y". It is not possible to specify months. 365.25 days is used as an
alias for a year.
If the string is suffixed with "ago", or prefixed with a "-", the resulting time span gets
subtracted from the current unix timestamp. A "from now" suffix can also be used for
readability when adding to the current unix timestamp.setExpirationTime(import envenv.ACCESS_TOKEN_TTL)
.SignJWT.sign(key: CryptoKey | KeyObject | JWK | Uint8Array, options?: SignOptions): Promise<string>Signs and returns the JWT.sign(const accessSecret: Uint8Array<ArrayBuffer>accessSecret);
}
export async function function signRefreshToken(payload: JWTPayload): Promise<string>signRefreshToken(payload: JWTPayloadpayload: JWTPayload) {
return new new SignJWT(payload?: JWTPayload): SignJWT
{@link
SignJWT
}
constructorSignJWT(payload: JWTPayloadpayload)
.SignJWT.setProtectedHeader(protectedHeader: JWTHeaderParameters): SignJWTSets the JWS Protected Header on the SignJWT object.setProtectedHeader({ CompactJWSHeaderParameters.alg: stringJWS "alg" (Algorithm) Header Parameteralg: 'HS256' })
.SignJWT.setIssuedAt(input?: number | string | Date): SignJWTSet the "iat" (Issued At) Claim.
- If no argument is used the current unix timestamp is used as the claim.
- If a `number` is passed as an argument it is used as the claim directly.
- If a `Date` instance is passed as an argument it is converted to unix timestamp and used as the
claim.
- If a `string` is passed as an argument it is resolved to a time span, and then added to the
current unix timestamp and used as the claim.
Format used for time span should be a number followed by a unit, such as "5 minutes" or "1
day".
Valid units are: "sec", "secs", "second", "seconds", "s", "minute", "minutes", "min", "mins",
"m", "hour", "hours", "hr", "hrs", "h", "day", "days", "d", "week", "weeks", "w", "year",
"years", "yr", "yrs", and "y". It is not possible to specify months. 365.25 days is used as an
alias for a year.
If the string is suffixed with "ago", or prefixed with a "-", the resulting time span gets
subtracted from the current unix timestamp. A "from now" suffix can also be used for
readability when adding to the current unix timestamp.setIssuedAt()
.SignJWT.setExpirationTime(input: number | string | Date): SignJWTSet the "exp" (Expiration Time) Claim.
- If a `number` is passed as an argument it is used as the claim directly.
- If a `Date` instance is passed as an argument it is converted to unix timestamp and used as the
claim.
- If a `string` is passed as an argument it is resolved to a time span, and then added to the
current unix timestamp and used as the claim.
Format used for time span should be a number followed by a unit, such as "5 minutes" or "1
day".
Valid units are: "sec", "secs", "second", "seconds", "s", "minute", "minutes", "min", "mins",
"m", "hour", "hours", "hr", "hrs", "h", "day", "days", "d", "week", "weeks", "w", "year",
"years", "yr", "yrs", and "y". It is not possible to specify months. 365.25 days is used as an
alias for a year.
If the string is suffixed with "ago", or prefixed with a "-", the resulting time span gets
subtracted from the current unix timestamp. A "from now" suffix can also be used for
readability when adding to the current unix timestamp.setExpirationTime(import envenv.REFRESH_TOKEN_TTL)
.SignJWT.sign(key: CryptoKey | KeyObject | JWK | Uint8Array, options?: SignOptions): Promise<string>Signs and returns the JWT.sign(const refreshSecret: Uint8Array<ArrayBuffer>refreshSecret);
}
export async function function verifyAccessToken(token: string): Promise<JWTPayload>verifyAccessToken(token: stringtoken: string) {
const { const payload: JWTPayloadJWT Claims Set.payload } = await jwtVerify<JWTPayload>(jwt: string | Uint8Array, key: CryptoKey | KeyObject | JWK | Uint8Array, options?: JWTVerifyOptions): Promise<JWTVerifyResult<JWTPayload>> (+1 overload)Verifies the JWT format (to be a JWS Compact format), verifies the JWS signature, validates the
JWT Claims Set.
This function is exported (as a named export) from the main `'jose'` module entry point as well
as from its subpath export `'jose/jwt/verify'`.jwtVerify(token: stringtoken, const accessSecret: Uint8Array<ArrayBuffer>accessSecret);
return const payload: JWTPayloadJWT Claims Set.payload;
}
export async function function verifyRefreshToken(token: string): Promise<JWTPayload>verifyRefreshToken(token: stringtoken: string) {
const { const payload: JWTPayloadJWT Claims Set.payload } = await jwtVerify<JWTPayload>(jwt: string | Uint8Array, key: CryptoKey | KeyObject | JWK | Uint8Array, options?: JWTVerifyOptions): Promise<JWTVerifyResult<JWTPayload>> (+1 overload)Verifies the JWT format (to be a JWS Compact format), verifies the JWS signature, validates the
JWT Claims Set.
This function is exported (as a named export) from the main `'jose'` module entry point as well
as from its subpath export `'jose/jwt/verify'`.jwtVerify(token: stringtoken, const refreshSecret: Uint8Array<ArrayBuffer>refreshSecret);
return const payload: JWTPayloadJWT Claims Set.payload;
}
7) Auth routes (signup, login, refresh, logout, me)
src/features/auth/schema.ts:
import { import zz } from 'zod';
export const const signUpSchema: z.ZodObject<{
fullName: z.ZodString;
email: z.ZodString;
password: z.ZodString;
}, z.core.$strip>
signUpSchema = import zz.function object<{
fullName: z.ZodString;
email: z.ZodString;
password: z.ZodString;
}>(shape?: {
fullName: z.ZodString;
email: z.ZodString;
password: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
fullName: z.ZodString;
email: z.ZodString;
password: z.ZodString;
}, z.core.$strip>
object({
fullName: z.ZodStringfullName: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(3)._ZodString<$ZodStringInternals<string>>.max(maxLength: number, params?: string | z.core.$ZodCheckMaxLengthParams): z.ZodStringmax(80),
email: z.ZodStringemail: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodString.email(params?: string | z.core.$ZodCheckEmailParams): z.ZodStringemail(),
password: z.ZodStringpassword: import zz
.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()
._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(10)
._ZodString<$ZodStringInternals<string>>.regex(regex: RegExp, params?: string | z.core.$ZodCheckRegexParams): z.ZodStringregex(/[A-Z]/, 'Must contain an uppercase letter')
._ZodString<$ZodStringInternals<string>>.regex(regex: RegExp, params?: string | z.core.$ZodCheckRegexParams): z.ZodStringregex(/[a-z]/, 'Must contain a lowercase letter')
._ZodString<$ZodStringInternals<string>>.regex(regex: RegExp, params?: string | z.core.$ZodCheckRegexParams): z.ZodStringregex(/[0-9]/, 'Must contain a number')
._ZodString<$ZodStringInternals<string>>.regex(regex: RegExp, params?: string | z.core.$ZodCheckRegexParams): z.ZodStringregex(/[^A-Za-z0-9]/, 'Must contain a symbol'),
});
export const const loginSchema: z.ZodObject<{
email: z.ZodString;
password: z.ZodString;
}, z.core.$strip>
loginSchema = import zz.function object<{
email: z.ZodString;
password: z.ZodString;
}>(shape?: {
email: z.ZodString;
password: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
email: z.ZodString;
password: z.ZodString;
}, z.core.$strip>
object({
email: z.ZodStringemail: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string().ZodString.email(params?: string | z.core.$ZodCheckEmailParams): z.ZodStringemail(),
password: z.ZodStringpassword: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(1),
});
src/features/auth/routes.ts:
import { import RouterRouter, type import RequestRequest, type import ResponseResponse } from 'express';
import import argon2argon2 from 'argon2';
import { function randomUUID(options?: RandomUUIDOptions): UUIDGenerates a random [RFC 4122](https://www.rfc-editor.org/rfc/rfc4122.txt) version 4 UUID. The UUID is generated using a
cryptographic pseudorandom number generator.randomUUID } from 'node:crypto';
import { import prismaprisma } from '../../lib/prisma';
import { import loginSchemaloginSchema, import signUpSchemasignUpSchema } from './schema';
import { import envenv } from '../../config/env';
import {
import signAccessTokensignAccessToken,
import signRefreshTokensignRefreshToken,
import verifyAccessTokenverifyAccessToken,
import verifyRefreshTokenverifyRefreshToken,
} from '../../lib/tokens';
const const authRouter: anyauthRouter = import RouterRouter();
function function setRefreshCookie(res: Response, token: string): voidsetRefreshCookie(res: Responseres: import ResponseResponse, token: stringtoken: string) {
res: Responseres.cookie(import envenv.REFRESH_COOKIE_NAME, token: stringtoken, {
httpOnly: booleanhttpOnly: true,
secure: booleansecure: import envenv.NODE_ENV === 'production',
sameSite: stringsameSite: 'lax',
path: stringpath: '/v1/auth',
maxAge: numbermaxAge: 30 * 24 * 60 * 60 * 1000,
});
}
function function clearRefreshCookie(res: Response): voidclearRefreshCookie(res: Responseres: import ResponseResponse) {
res: Responseres.clearCookie(import envenv.REFRESH_COOKIE_NAME, {
httpOnly: booleanhttpOnly: true,
secure: booleansecure: import envenv.NODE_ENV === 'production',
sameSite: stringsameSite: 'lax',
path: stringpath: '/v1/auth',
});
}
const authRouter: anyauthRouter.post('/signup', async (req: anyreq, res: anyres) => {
const const parsed: anyparsed = import signUpSchemasignUpSchema.safeParse(req: anyreq.body);
if (!const parsed: anyparsed.success) return res: anyres.status(400).json(const parsed: anyparsed.error.flatten());
const { const fullName: anyfullName, const email: anyemail, const password: anypassword } = const parsed: anyparsed.data;
const const exists: anyexists = await import prismaprisma.user.findUnique({ where: {
email: any;
}
where: { email: anyemail: const email: anyemail.toLowerCase() } });
if (const exists: anyexists) return res: anyres.status(409).json({ message: stringmessage: 'Email already in use' });
const const passwordHash: anypasswordHash = await import argon2argon2.hash(const password: anypassword, { type: anytype: import argon2argon2.argon2id });
const const user: anyuser = await import prismaprisma.user.create({
data: {
fullName: any;
email: any;
passwordHash: any;
}
data: { fullName: anyfullName, email: anyemail: const email: anyemail.toLowerCase(), passwordHash: anypasswordHash },
select: {
id: boolean;
email: boolean;
fullName: boolean;
role: boolean;
}
select: { id: booleanid: true, email: booleanemail: true, fullName: booleanfullName: true, role: booleanrole: true },
});
const const jti: `${string}-${string}-${string}-${string}-${string}`jti = function randomUUID(options?: RandomUUIDOptions): UUIDGenerates a random [RFC 4122](https://www.rfc-editor.org/rfc/rfc4122.txt) version 4 UUID. The UUID is generated using a
cryptographic pseudorandom number generator.randomUUID();
const const refreshToken: anyrefreshToken = await import signRefreshTokensignRefreshToken({ sub: anysub: const user: anyuser.id, jti: `${string}-${string}-${string}-${string}-${string}`jti });
const const accessToken: anyaccessToken = await import signAccessTokensignAccessToken({ sub: anysub: const user: anyuser.id, role: anyrole: const user: anyuser.role });
await import prismaprisma.session.create({
data: {
userId: any;
jti: `${string}-${string}-${string}-${string}-${string}`;
userAgent: any;
ip: any;
expiresAt: Date;
}
data: {
userId: anyuserId: const user: anyuser.id,
jti: `${string}-${string}-${string}-${string}-${string}`jti,
userAgent: anyuserAgent: req: anyreq.headers['user-agent'] ?? null,
ip: anyip: req: anyreq.ip,
expiresAt: DateexpiresAt: new var Date: DateConstructor
new (value: number | string | Date) => Date (+4 overloads)
Date(var Date: DateConstructorEnables basic storage and retrieval of dates and times.Date.DateConstructor.now(): numberReturns the number of milliseconds elapsed since midnight, January 1, 1970 Universal Coordinated Time (UTC).now() + 30 * 24 * 60 * 60 * 1000),
},
});
function setRefreshCookie(res: Response, token: string): voidsetRefreshCookie(res: anyres, const refreshToken: anyrefreshToken);
return res: anyres.status(201).json({ accessToken: anyaccessToken, user: anyuser });
});
const authRouter: anyauthRouter.post('/login', async (req: anyreq, res: anyres) => {
const const parsed: anyparsed = import loginSchemaloginSchema.safeParse(req: anyreq.body);
if (!const parsed: anyparsed.success) return res: anyres.status(400).json(const parsed: anyparsed.error.flatten());
const { const email: anyemail, const password: anypassword } = const parsed: anyparsed.data;
const const user: anyuser = await import prismaprisma.user.findUnique({ where: {
email: any;
}
where: { email: anyemail: const email: anyemail.toLowerCase() } });
if (!const user: anyuser) return res: anyres.status(401).json({ message: stringmessage: 'Invalid credentials' });
const const ok: anyok = await import argon2argon2.verify(const user: anyuser.passwordHash, const password: anypassword);
if (!const ok: anyok) return res: anyres.status(401).json({ message: stringmessage: 'Invalid credentials' });
const const jti: `${string}-${string}-${string}-${string}-${string}`jti = function randomUUID(options?: RandomUUIDOptions): UUIDGenerates a random [RFC 4122](https://www.rfc-editor.org/rfc/rfc4122.txt) version 4 UUID. The UUID is generated using a
cryptographic pseudorandom number generator.randomUUID();
const const refreshToken: anyrefreshToken = await import signRefreshTokensignRefreshToken({ sub: anysub: const user: anyuser.id, jti: `${string}-${string}-${string}-${string}-${string}`jti });
const const accessToken: anyaccessToken = await import signAccessTokensignAccessToken({ sub: anysub: const user: anyuser.id, role: anyrole: const user: anyuser.role });
await import prismaprisma.session.create({
data: {
userId: any;
jti: `${string}-${string}-${string}-${string}-${string}`;
userAgent: any;
ip: any;
expiresAt: Date;
}
data: {
userId: anyuserId: const user: anyuser.id,
jti: `${string}-${string}-${string}-${string}-${string}`jti,
userAgent: anyuserAgent: req: anyreq.headers['user-agent'] ?? null,
ip: anyip: req: anyreq.ip,
expiresAt: DateexpiresAt: new var Date: DateConstructor
new (value: number | string | Date) => Date (+4 overloads)
Date(var Date: DateConstructorEnables basic storage and retrieval of dates and times.Date.DateConstructor.now(): numberReturns the number of milliseconds elapsed since midnight, January 1, 1970 Universal Coordinated Time (UTC).now() + 30 * 24 * 60 * 60 * 1000),
},
});
function setRefreshCookie(res: Response, token: string): voidsetRefreshCookie(res: anyres, const refreshToken: anyrefreshToken);
return res: anyres.status(200).json({
accessToken: anyaccessToken,
user: {
id: any;
email: any;
fullName: any;
role: any;
}
user: { id: anyid: const user: anyuser.id, email: anyemail: const user: anyuser.email, fullName: anyfullName: const user: anyuser.fullName, role: anyrole: const user: anyuser.role },
});
});
const authRouter: anyauthRouter.post('/refresh', async (req: anyreq, res: anyres) => {
const const token: anytoken = req: anyreq.cookies[import envenv.REFRESH_COOKIE_NAME];
if (!const token: anytoken) return res: anyres.status(401).json({ message: stringmessage: 'Missing refresh token' });
const const payload: anypayload = await import verifyRefreshTokenverifyRefreshToken(const token: anytoken);
const const jti: stringjti = var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.String(const payload: anypayload.jti ?? '');
const const userId: stringuserId = var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.String(const payload: anypayload.sub ?? '');
if (!const jti: stringjti || !const userId: stringuserId) return res: anyres.status(401).json({ message: stringmessage: 'Invalid refresh token' });
const const session: anysession = await import prismaprisma.session.findFirst({
where: {
jti: string;
userId: string;
revokedAt: null;
expiresAt: {
gt: Date;
};
}
where: {
jti: stringjti,
userId: stringuserId,
revokedAt: nullrevokedAt: null,
expiresAt: {
gt: Date;
}
expiresAt: { gt: Dategt: new var Date: DateConstructor
new () => Date (+4 overloads)
Date() },
},
});
if (!const session: anysession) return res: anyres.status(401).json({ message: stringmessage: 'Session expired or revoked' });
const const nextJti: `${string}-${string}-${string}-${string}-${string}`nextJti = function randomUUID(options?: RandomUUIDOptions): UUIDGenerates a random [RFC 4122](https://www.rfc-editor.org/rfc/rfc4122.txt) version 4 UUID. The UUID is generated using a
cryptographic pseudorandom number generator.randomUUID();
const [const user: anyuser] = await import prismaprisma.$transaction([
import prismaprisma.user.findUniqueOrThrow({ where: {
id: string;
}
where: { id: stringid: const userId: stringuserId } }),
import prismaprisma.session.update({ where: {
jti: string;
}
where: { jti: stringjti }, data: {
revokedAt: Date;
}
data: { revokedAt: DaterevokedAt: new var Date: DateConstructor
new () => Date (+4 overloads)
Date() } }),
import prismaprisma.session.create({
data: {
userId: string;
jti: `${string}-${string}-${string}-${string}-${string}`;
userAgent: any;
ip: any;
expiresAt: Date;
}
data: {
userId: stringuserId,
jti: `${string}-${string}-${string}-${string}-${string}`jti: const nextJti: `${string}-${string}-${string}-${string}-${string}`nextJti,
userAgent: anyuserAgent: req: anyreq.headers['user-agent'] ?? null,
ip: anyip: req: anyreq.ip,
expiresAt: DateexpiresAt: new var Date: DateConstructor
new (value: number | string | Date) => Date (+4 overloads)
Date(var Date: DateConstructorEnables basic storage and retrieval of dates and times.Date.DateConstructor.now(): numberReturns the number of milliseconds elapsed since midnight, January 1, 1970 Universal Coordinated Time (UTC).now() + 30 * 24 * 60 * 60 * 1000),
},
}),
]);
const const nextRefresh: anynextRefresh = await import signRefreshTokensignRefreshToken({ sub: anysub: const user: anyuser.id, jti: `${string}-${string}-${string}-${string}-${string}`jti: const nextJti: `${string}-${string}-${string}-${string}-${string}`nextJti });
const const nextAccess: anynextAccess = await import signAccessTokensignAccessToken({ sub: anysub: const user: anyuser.id, role: anyrole: const user: anyuser.role });
function setRefreshCookie(res: Response, token: string): voidsetRefreshCookie(res: anyres, const nextRefresh: anynextRefresh);
return res: anyres.status(200).json({ accessToken: anyaccessToken: const nextAccess: anynextAccess });
});
const authRouter: anyauthRouter.post('/logout', async (req: anyreq, res: anyres) => {
const const token: anytoken = req: anyreq.cookies[import envenv.REFRESH_COOKIE_NAME];
if (const token: anytoken) {
try {
const const payload: anypayload = await import verifyRefreshTokenverifyRefreshToken(const token: anytoken);
const const jti: stringjti = var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.String(const payload: anypayload.jti ?? '');
if (const jti: stringjti) await import prismaprisma.session.updateMany({ where: {
jti: string;
}
where: { jti: stringjti }, data: {
revokedAt: Date;
}
data: { revokedAt: DaterevokedAt: new var Date: DateConstructor
new () => Date (+4 overloads)
Date() } });
} catch {
// Invalid token: still clear cookie and return 204.
}
}
function clearRefreshCookie(res: Response): voidclearRefreshCookie(res: anyres);
return res: anyres.status(204).send();
});
const authRouter: anyauthRouter.get('/me', async (req: Requestreq: import RequestRequest, res: Responseres: import ResponseResponse) => {
const const auth: anyauth = req: Requestreq.headers.authorization;
if (!const auth: anyauth?.startsWith('Bearer ')) return res: Responseres.status(401).json({ message: stringmessage: 'Unauthorized' });
const const token: anytoken = const auth: anyauth.slice('Bearer '.String.length: numberReturns the length of a String object.length);
const const payload: anypayload = await import verifyAccessTokenverifyAccessToken(const token: anytoken);
const const userId: stringuserId = var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.String(const payload: anypayload.sub ?? '');
const const user: anyuser = await import prismaprisma.user.findUnique({
where: {
id: string;
}
where: { id: stringid: const userId: stringuserId },
select: {
id: boolean;
email: boolean;
fullName: boolean;
role: boolean;
createdAt: boolean;
}
select: { id: booleanid: true, email: booleanemail: true, fullName: booleanfullName: true, role: booleanrole: true, createdAt: booleancreatedAt: true },
});
if (!const user: anyuser) return res: Responseres.status(404).json({ message: stringmessage: 'User not found' });
return res: Responseres.status(200).json({ user: anyuser });
});
export { const authRouter: any
export authRouter
authRouter };
For very high-security apps, use opaque refresh tokens (random bytes + DB hash) instead of JWT refresh tokens.
8) Minimal API test
src/features/auth/auth.test.ts:
import import requestrequest from 'supertest';
import { import describedescribe, import expectexpect, import itit } from 'vitest';
import { import appapp } from '../../app';
import describedescribe('health', () => {
import itit('returns OK', async () => {
const const res: anyres = await import requestrequest(import appapp).get('/health');
import expectexpect(const res: anyres.status).toBe(200);
import expectexpect(const res: anyres.body).toEqual({ ok: booleanok: true });
});
});
Run tests:
bun run test
9) Production hardening checklist
- Use HTTPS everywhere.
- Set
secure: truecookies in production. - Rotate JWT secrets regularly.
- Add per-route auth rate limits (
/login,/refresh). - Add request ID + structured logs + central log shipping.
- Add DB backups and migration strategy.
- Add OpenAPI docs and contract tests.
- Add CI: lint, typecheck, test, migration check.
10) Quick run
# 1) start db
docker compose up -d
# 2) run migrations
bunx prisma migrate dev --name init_auth
# 3) start api
bun run dev