Skip to main content

Backend Starter

Home/projects/Backend Starter

[Back-end]

Backend Starter

A production-ready Express backend starter with Bun, TypeScript, PostgreSQL, Prisma, and secure JWT auth with rotating refresh sessions.

BunExpressTypeScriptPostgreSQLDockerPrismaJWT
View repository

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: jose JWT + 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.ZodString
@deprecatedUse `z.url()` instead.
url
(),
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.js
@example``` const prisma = new PrismaClient() // Fetch zero or more BlogPosts const blogPosts = await prisma.blogPost.findMany() ``` Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
PrismaClient
} 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.js
@example``` const prisma = new PrismaClient() // Fetch zero or more BlogPosts const blogPosts = await prisma.blogPost.findMany() ``` Read more in our [docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client).
PrismaClient
();

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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.
@parampassedOptions - Options to configure the rate limiter.@returns- The middleware that rate-limits clients based on your configuration.@public
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.
@parampassedOptions - Options to configure the rate limiter.@returns- The middleware that rate-limits clients based on your configuration.@public
rateLimit
({
windowMs?: number | undefined
How long we should remember the requests. Defaults to `60000` ms (= 1 minute).
windowMs
: 60_000,
max?: number | ValueDeterminingMiddleware<number> | undefined
The 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.
@deprecated7.x - This option was renamed to `limit`. However, it will not be removed from the library in the foreseeable future.
max
: 120,
standardHeaders?: boolean | "draft-6" | "draft-7" | "draft-8" | undefined
Whether 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 | undefined
Whether 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 SignJWT
The 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'`.
@paramjwt JSON Web Token value (encoded as JWS).@paramkey Key to verify the JWT with. See {@link https://github.com/panva/jose/issues/210#jws-alg Algorithm Key Requirements}.@paramoptions JWT Decryption and JWT Claims Set validation options.
jwtVerify
} from 'jose';
import { import envenv } from '../config/env'; const const accessSecret: Uint8Array<ArrayBuffer>accessSecret = new var TextEncoder: new () => TextEncoder
The **`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 () => TextEncoder
The **`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 } constructor
@parampayload The JWT Claims Set object. Defaults to an empty object.
SignJWT
(payload: JWTPayloadpayload)
.SignJWT.setProtectedHeader(protectedHeader: JWTHeaderParameters): SignJWT
Sets the JWS Protected Header on the SignJWT object.
@paramprotectedHeader JWS Protected Header. Must contain an "alg" (JWS Algorithm) property.
setProtectedHeader
({ CompactJWSHeaderParameters.alg: string
JWS "alg" (Algorithm) Header Parameter
@see{@link https://github.com/panva/jose/issues/210#jws-alg Algorithm Key Requirements}
alg
: 'HS256' })
.SignJWT.setIssuedAt(input?: number | string | Date): SignJWT
Set 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.
@paraminput "iat" (Expiration Time) Claim value to set on the JWT Claims Set.
setIssuedAt
()
.SignJWT.setExpirationTime(input: number | string | Date): SignJWT
Set 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.
@paraminput "exp" (Expiration Time) Claim value to set on the JWT Claims Set.
setExpirationTime
(import envenv.ACCESS_TOKEN_TTL)
.SignJWT.sign(key: CryptoKey | KeyObject | JWK | Uint8Array, options?: SignOptions): Promise<string>
Signs and returns the JWT.
@paramkey Private Key or Secret to sign the JWT with. See {@link https://github.com/panva/jose/issues/210#jws-alg Algorithm Key Requirements}.@paramoptions JWT Sign options.
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 } constructor
@parampayload The JWT Claims Set object. Defaults to an empty object.
SignJWT
(payload: JWTPayloadpayload)
.SignJWT.setProtectedHeader(protectedHeader: JWTHeaderParameters): SignJWT
Sets the JWS Protected Header on the SignJWT object.
@paramprotectedHeader JWS Protected Header. Must contain an "alg" (JWS Algorithm) property.
setProtectedHeader
({ CompactJWSHeaderParameters.alg: string
JWS "alg" (Algorithm) Header Parameter
@see{@link https://github.com/panva/jose/issues/210#jws-alg Algorithm Key Requirements}
alg
: 'HS256' })
.SignJWT.setIssuedAt(input?: number | string | Date): SignJWT
Set 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.
@paraminput "iat" (Expiration Time) Claim value to set on the JWT Claims Set.
setIssuedAt
()
.SignJWT.setExpirationTime(input: number | string | Date): SignJWT
Set 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.
@paraminput "exp" (Expiration Time) Claim value to set on the JWT Claims Set.
setExpirationTime
(import envenv.REFRESH_TOKEN_TTL)
.SignJWT.sign(key: CryptoKey | KeyObject | JWK | Uint8Array, options?: SignOptions): Promise<string>
Signs and returns the JWT.
@paramkey Private Key or Secret to sign the JWT with. See {@link https://github.com/panva/jose/issues/210#jws-alg Algorithm Key Requirements}.@paramoptions JWT Sign options.
sign
(const refreshSecret: Uint8Array<ArrayBuffer>refreshSecret);
} export async function function verifyAccessToken(token: string): Promise<JWTPayload>verifyAccessToken(token: stringtoken: string) { const { const payload: JWTPayload
JWT 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'`.
@paramjwt JSON Web Token value (encoded as JWS).@paramkey Key to verify the JWT with. See {@link https://github.com/panva/jose/issues/210#jws-alg Algorithm Key Requirements}.@paramoptions JWT Decryption and JWT Claims Set validation options.
jwtVerify
(token: stringtoken, const accessSecret: Uint8Array<ArrayBuffer>accessSecret);
return const payload: JWTPayload
JWT Claims Set.
payload
;
} export async function function verifyRefreshToken(token: string): Promise<JWTPayload>verifyRefreshToken(token: stringtoken: string) { const { const payload: JWTPayload
JWT 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'`.
@paramjwt JSON Web Token value (encoded as JWS).@paramkey Key to verify the JWT with. See {@link https://github.com/panva/jose/issues/210#jws-alg Algorithm Key Requirements}.@paramoptions JWT Decryption and JWT Claims Set validation options.
jwtVerify
(token: stringtoken, const refreshSecret: Uint8Array<ArrayBuffer>refreshSecret);
return const payload: JWTPayload
JWT 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.ZodString
@deprecatedUse `z.email()` instead.
email
(),
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.ZodString
@deprecatedUse `z.email()` instead.
email
(),
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): UUID
Generates 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.
@sincev15.6.0, v14.17.0
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): UUID
Generates 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.
@sincev15.6.0, v14.17.0
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: DateConstructor
Enables basic storage and retrieval of dates and times.
Date
.DateConstructor.now(): number
Returns 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): UUID
Generates 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.
@sincev15.6.0, v14.17.0
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: DateConstructor
Enables basic storage and retrieval of dates and times.
Date
.DateConstructor.now(): number
Returns 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): UUID
Generates 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.
@sincev15.6.0, v14.17.0
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: DateConstructor
Enables basic storage and retrieval of dates and times.
Date
.DateConstructor.now(): number
Returns 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: number
Returns 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: true cookies 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