How to buildOctober 11, 202614 min read
Backend with Node.jsStep 5 of 8

Authentication: sessions, cookies and JWT

In short

For a website with its own backend, a server-side session is the most reliable option: after the password check the server creates a record, and the browser gets a random identifier in a cookie with HttpOnly, Secure and SameSite. JWT fits when other services or a mobile client verify the token, but it is hard to revoke before it expires, so it lives for minutes and logout needs refresh tokens or a revocation list. Tokens do not belong in localStorage, and cookie-based requests need CSRF protection.

Deep diveLevel: IntermediateAuthentication

After this you will be able to

  • compare server-side sessions and JWT on revocation, storage and risk, and pick the right one for a project
  • configure a session cookie with HttpOnly, Secure, SameSite, the __Host- prefix and timeouts
  • verify a JWT on the server: allowed alg, signature, exp, iss, aud and token type
  • write supertest tests for a wrong password, access without a cookie, logout and a cross-site request

Verified with

Node.js 24 LTS (24.18)Express 5.3express-session 1.19argon2 0.45jose 6.2TypeScript 7.0Vitest 5.0supertest 7.3

Checked against docs: October 11, 2026

Contents
  1. 01Authentication vs authorization
  2. 02Server-side sessions and cookies
  3. 03Cookie attributes: HttpOnly, Secure, SameSite and lifetime
  4. 04JWT: structure, signature and verification
  5. 05Where to store the token, refresh and revocation
  6. 06CSRF: why cookies need protection from other sites
  7. 07Example: Express 5 login with Argon2id and a session
  8. 08Testing, the JWT variant and what production needs

Authentication vs authorization

Authentication answers “who is this?”: the user proves they own the account with a password, a one-time code or a passkey. Authorization answers “what are they allowed to do?”: may they see this lead, may they delete that user. These are two separate steps, and a mistake in either one leaks data. This lesson covers the first step; permissions are the topic of the next lesson, “Authorization: roles, permissions and server-side access checks”.

HTTP keeps no state between requests: the server does not remember that the same browser entered the right password a minute ago. So after login the client receives something it presents with every following request. There are two common options: a server-side session, where the cookie holds only a random identifier and the data stays on the server, and a self-contained token, most often a JWT (JSON Web Token), where the user data is signed and travels with the request.

Server-side sessions and cookies

The classic scheme is easy to reason about and easy to revoke. The server keeps the session record in memory, Redis or a database, and the browser holds only the key to it. Here is what happens from the password form to logout:

  1. Password check

    The server looks up the user and compares the password with an Argon2id hash. “No such email” and “wrong password” get the same response, so nobody can enumerate registered accounts.

  2. New session identifier

    After a successful login the ID is issued anew. Otherwise session fixation works: an attacker plants a known ID in the victim’s browser and is inside once the victim logs in.

  3. Cookie with the identifier

    The response carries a Set-Cookie header with a random value. OWASP requires at least 64 bits of entropy from a cryptographically secure generator; express-session generates the ID for you.

  4. Every following request

    The browser attaches the cookie, the server finds the record, checks the timeouts and knows the user.

  5. Logout

    The session record is deleted on the server and the cookie is cleared in the browser. After that even a stolen copy of the identifier is useless.

A session has two deadlines. The idle timeout closes it when the user has been inactive for a while; the absolute timeout closes it a fixed time after login, even with constant activity. OWASP gives reference points: 15–30 minutes of inactivity for low-risk applications and 2–5 minutes for high-value ones, and 4–8 hours of absolute lifetime for an office tool used all day. The right values depend on what the application protects.

Cookie attributes: HttpOnly, Secure, SameSite and lifetime

A session is only as safe as its cookie. Attributes are set in the Set-Cookie header, and for a session cookie the set is almost always the same:

HttpOnly

What it does
Prevents JavaScript from reading the cookie via document.cookie. Requests made with fetch still send it
What to set for a session
Always: XSS can’t steal the identifier

Secure

What it does
The cookie is sent over HTTPS only. Browsers make an exception for localhost
What to set for a session
Always

SameSite

What it does
Strict — same-site requests only; Lax — plus top-level navigations from other sites with safe methods; None — always, and only together with Secure
What to set for a session
Strict or Lax, explicitly: browser defaults differ

Expires / Max-Age

What it does
Lifetime. Without them the cookie is a “session cookie”, but browsers with session restore bring it back too
What to set for a session
A lifetime plus mandatory timeout checks on the server

Domain / Path

What it does
Without Domain only the host that set the cookie receives it, not subdomains
What to set for a session
Path=/, no Domain

__Host- prefix

What it does
The browser accepts the cookie only with Secure, Path=/ and no Domain
What to set for a session
Recommended by OWASP for session IDs

Choosing between Strict and Lax is an engineering trade-off. Strict won’t send the cookie even when the user follows a link to your site from an email: they see the page as a guest although they are logged in. Lax sends the cookie on such a navigation but not on a cross-site POST. Lax is usually enough for a customer account; Strict makes sense for banking operations. Either way SameSite is an extra layer, not a complete CSRF defense; more on that below.

JWT: structure, signature and verification

RFC 7519 defines JWT as a compact way to transmit a set of claims as JSON. A signed token has three dot-separated parts — header, payload and signature — each base64url-encoded. The signature, either an HMAC with a shared secret (e.g. HS256) or a private-key signature (ES256, EdDSA), guarantees the content was not changed. But base64url is not encryption: anyone holding the token can read every claim. Keep ID documents and phone numbers out of a JWT.

  • sub — who the token was issued to (user ID)
  • iss — who issued it; aud — which service it is meant for
  • exp — the time from which the token must be rejected; iat — when it was issued
  • jti — a unique token ID, needed for logs and revocation lists

Most attacks on JWT don’t break the cryptography; they exploit lenient validation. RFC 8725 and the OWASP JSON Web Token Cheat Sheet boil it down to a few rules:

  • List the allowed algorithms explicitly. A token with "alg": "none" is never accepted, and HMAC and public-key algorithms are never mixed — otherwise algorithm confusion becomes possible
  • Check exp and require the claims you rely on: jose has a requiredClaims option for that
  • Validate iss and aud: a token issued for another service of the same issuer must be rejected
  • Set an explicit typ header and check it, so a password-reset token can’t be presented as an access token (RFC 8725, §3.11)
  • An HS256 secret has at least 256 bits from a cryptographically secure generator — not a password and not a string in the repository
  • Never take the verification key from the token itself (jwk, jku, x5u): the attacker controls the header

Where to store the token, refresh and revocation

The most common choice in tutorials — putting the JWT into localStorage — is something OWASP explicitly forbids. localStorage and sessionStorage are readable by any script on the page, so a single XSS flaw discloses every token at once. The recommendation is a cookie with HttpOnly; Secure; SameSite=Strict or a BFF (Backend-for-Frontend) pattern, where your server keeps the tokens and the browser gets a regular session.

Where state lives

Server-side session
In the session store on the server
JWT
In the token itself; the server checks the signature

Logout and blocking

Server-side session
Delete the record and access ends at once
JWT
The token is valid until exp; you need a revocation list or a short lifetime

What the client sees

Server-side session
A meaningless random string
JWT
All claims in plain text

Verification by other services

Server-side session
Requires access to a shared store
JWT
The verification key is enough

Typical client

Server-side session
A website or web app with its own backend
JWT
A mobile app, service-to-service calls, OAuth / OpenID Connect

Because revocation is hard, access tokens are short-lived — minutes — and a refresh token is issued to extend the session. RFC 9700, the current OAuth 2.0 security best practice, requires detecting refresh token replay for public clients, i.e. applications that can’t keep a secret. One way is rotation: every refresh returns a new refresh token, the old one becomes invalid, and presenting the old one again revokes the whole chain. That is the signal the token was stolen.

If access tokens must be revoked anyway (logout, password change, blocking), OWASP suggests a denylist keyed by jti and iss, kept until exp. The key must not be the token itself or its hash: because of encoding ambiguity an attacker may produce a different string with the same valid signature. Issuers serving many services have a standardized mechanism, the Token Status List.

CSRF: why cookies need protection from other sites

The browser attaches cookies to requests to your site automatically — even when an attacker’s page sent the request. That is the basis of CSRF (Cross-Site Request Forgery): a hidden form on another site POSTs to your /transfer, and the logged-in user’s browser adds the session cookie. The browser never adds an Authorization: Bearer header on its own, so CSRF is primarily a problem of cookie-based authentication.

  • Your framework’s built-in protection, if it has one, is OWASP’s first choice
  • CSRF tokens: the synchronizer token pattern for stateful apps, the signed double-submit cookie for stateless ones
  • Fetch Metadata headers: with Sec-Fetch-Site the server sees whether a request came from its own origin (same-origin) or another site (cross-site). OWASP notes support in all major browsers since March 2023 and requires an Origin fallback for clients without these headers
  • SameSite on the session cookie is an extra layer but not the only defense: it has limits, such as a registrable domain shared with subdomains you don’t control
  • No state changes via GET: SameSite=Lax lets cross-site GET navigations through

A special case is login CSRF: the attacker silently logs the victim into the attacker’s account, and the victim types card details into it. That is why the request-origin check in our example sits in front of every state-changing route, /login included.

Example: Express 5 login with Argon2id and a session

You need Node.js 24: it runs TypeScript files directly by stripping the types, so there is no build step. The project is a plain folder with package.json:

bash
npm init -y
npm pkg set type=module
npm install express@5 express-session@1 argon2@0.45 jose@6
npm install -D typescript @types/node@24 @types/express@5 @types/express-session vitest supertest @types/supertest
Dependencies; the versions the example was verified with are listed at the top of the lesson
tsconfig.json
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "rewriteRelativeImportExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Settings from the Node.js docs for running .ts without a build; tsc only type-checks

Passwords are hashed with Argon2id, the first choice in the OWASP Password Storage Cheat Sheet. The OWASP minimum is 19 MiB of memory, 2 passes and 1 lane; the argon2 package defaults (64 MiB, 3 passes, 4 lanes) exceed it and are stored right in the hash string. OWASP keeps bcrypt for legacy systems: it limits passwords to 72 bytes. Parameter tuning, rehashing and migrating from old algorithms will be covered in a separate password storage recipe.

src/users.ts
import * as argon2 from "argon2";
import { randomUUID } from "node:crypto";

export type Role = "admin" | "manager" | "analyst";

export type User = {
  id: string;
  email: string;
  role: Role;
  passwordHash: string;
};

// Учебное хранилище в памяти. В реальном проекте — таблица в базе данных.
const usersByEmail = new Map<string, User>();

export async function createUser(email: string, password: string, role: Role): Promise<User> {
  const user: User = {
    id: randomUUID(),
    email: email.toLowerCase(),
    role,
    // Argon2id с параметрами библиотеки по умолчанию; соль генерируется сама
    passwordHash: await argon2.hash(password),
  };
  usersByEmail.set(user.email, user);
  return user;
}

export function findUserById(id: string): User | undefined {
  for (const user of usersByEmail.values()) {
    if (user.id === id) return user;
  }
  return undefined;
}

// Хеш-заглушка: для несуществующего email проверка идёт так же долго,
// и время ответа не выдаёт, зарегистрирован ли адрес
const dummyHash = argon2.hash("timing-equalizer-not-a-real-password");

export async function verifyCredentials(email: string, password: string): Promise<User | null> {
  const user = usersByEmail.get(email.toLowerCase());
  const valid = await argon2.verify(user?.passwordHash ?? (await dummyHash), password);
  return user && valid ? user : null;
}
Users and password check. The dummy hash evens out response time for unknown emails

The app puts together everything discussed above: the request-origin check, a session with a secure cookie, a new ID after login, an absolute timeout and logout that deletes the session on the server. rolling: true together with maxAge gives the idle timeout; requireAuth enforces the absolute one based on the login time.

src/app.ts
import express, { type NextFunction, type Request, type Response } from "express";
import session from "express-session";
import { findUserById, verifyCredentials, type User } from "./users.ts";

declare module "express-session" {
  interface SessionData {
    userId: string;
    authenticatedAt: number;
  }
}

declare global {
  namespace Express {
    interface Locals {
      user?: User;
    }
  }
}

export const SESSION_COOKIE = "__Host-sid";
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 минут без запросов
const ABSOLUTE_TIMEOUT_MS = 8 * 60 * 60 * 1000; // 8 часов с момента входа
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);

// CSRF: изменяющие запросы принимаем только со своего сайта.
// Основной сигнал — Sec-Fetch-Site, запасной — Origin; без обоих — отказ.
function rejectCrossSite(appOrigin: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (SAFE_METHODS.has(req.method)) return next();
    const site = req.get("Sec-Fetch-Site");
    const sameOrigin = site ? site === "same-origin" : req.get("Origin") === appOrigin;
    if (!sameOrigin) {
      res.status(403).json({ error: "cross_site_request" });
      return;
    }
    next();
  };
}

export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const { userId, authenticatedAt } = req.session;
  const user = userId ? findUserById(userId) : undefined;
  const expired = !authenticatedAt || Date.now() - authenticatedAt > ABSOLUTE_TIMEOUT_MS;
  if (!user || expired) {
    req.session.destroy(() => res.status(401).json({ error: "unauthenticated" }));
    return;
  }
  res.locals.user = user;
  next();
}

export function createApp(options: { sessionSecret: string; appOrigin: string }) {
  if (options.sessionSecret.length < 32) throw new Error("SESSION_SECRET is too short");

  const app = express();
  app.set("trust proxy", 1); // HTTPS завершается на прокси (Nginx, балансировщик)
  app.use(express.json({ limit: "10kb" }));
  app.use(rejectCrossSite(options.appOrigin));
  app.use(
    session({
      name: SESSION_COOKIE,
      secret: options.sessionSecret,
      resave: false,
      saveUninitialized: false, // не создаём сессию до входа
      rolling: true, // продлеваем cookie при каждом ответе: тайм-аут бездействия
      cookie: {
        httpOnly: true,
        secure: true,
        sameSite: "lax",
        path: "/",
        maxAge: IDLE_TIMEOUT_MS,
      },
    }),
  );

  app.post("/login", async (req, res, next) => {
    const { email, password } = req.body ?? {};
    if (typeof email !== "string" || typeof password !== "string" || password.length > 256) {
      res.status(400).json({ error: "invalid_request" });
      return;
    }
    const user = await verifyCredentials(email, password);
    if (!user) {
      // Один и тот же ответ для «нет такого email» и «неверный пароль»
      res.status(401).json({ error: "invalid_credentials" });
      return;
    }
    // Новый ID сессии после входа — защита от фиксации сессии
    req.session.regenerate((err) => {
      if (err) return next(err);
      req.session.userId = user.id;
      req.session.authenticatedAt = Date.now();
      req.session.save((err) => (err ? next(err) : res.status(204).end()));
    });
  });

  app.get("/me", requireAuth, (req, res) => {
    const { id, email, role } = res.locals.user!;
    res.json({ id, email, role });
  });

  app.post("/logout", (req, res, next) => {
    // Сессия удаляется на сервере: украденная копия cookie тоже перестаёт работать
    req.session.destroy((err) => {
      if (err) return next(err);
      res.clearCookie(SESSION_COOKIE, { path: "/", httpOnly: true, secure: true, sameSite: "lax" });
      res.status(204).end();
    });
  });

  return app;
}
The app: CSRF check, session, login, profile and logout

app.set("trust proxy", 1) says there is exactly one reverse proxy in front of the app that terminates HTTPS and passes the protocol in X-Forwarded-Proto. The Express documentation warns that the setting must match the real deployment, otherwise clients can supply those headers themselves. If the app faces the internet directly, don’t enable trust proxy.

src/server.ts
import { createApp } from "./app.ts";
import { createUser } from "./users.ts";

const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) throw new Error("SESSION_SECRET is not set");
const port = Number(process.env.PORT ?? 3000);

const app = createApp({
  sessionSecret,
  appOrigin: process.env.APP_ORIGIN ?? `http://localhost:${port}`,
});

if (process.env.NODE_ENV !== "production") {
  // Демо-пользователь только для локального запуска
  await createUser("admin@example.com", "correct horse battery staple", "admin");
}

app.listen(port, () => console.log(`http://localhost:${port}`));
Startup: the secret comes only from an environment variable
bash
npx tsc -p .
export SESSION_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")
PORT=3917 node src/server.ts
Type check and start the server
terminal
$ curl -i -X POST http://127.0.0.1:3917/login \
    -H "Content-Type: application/json" -H "Origin: http://localhost:3917" \
    -H "X-Forwarded-Proto: https" \
    -d '{"email":"admin@example.com","password":"correct horse battery staple"}'
HTTP/1.1 204 No Content
Set-Cookie: __Host-sid=s%3AfuTZ…; Path=/; Expires=Sun, 11 Oct 2026 23:47:55 GMT; HttpOnly; Secure; SameSite=Lax

$ curl http://127.0.0.1:3917/me -H "Cookie: __Host-sid=s%3AfuTZ…"
{"id":"42c620b1-f267-4410-8b36-bf939acd4128","email":"admin@example.com","role":"admin"}

$ curl -i -X POST http://127.0.0.1:3917/logout -H "Cookie: __Host-sid=s%3AfuTZ…"
HTTP/1.1 403 Forbidden
Manual check with curl, output shortened. X-Forwarded-Proto imitates the HTTPS proxy; the request without Origin is rejected by the CSRF check

A common trap: login returns 204 but there is no Set-Cookie header. That is how express-session behaves when the cookie is marked Secure and the request arrived over plain HTTP — we reproduced it by sending the same request without X-Forwarded-Proto. Fix HTTPS on the proxy and the trust proxy setting instead of dropping Secure.

Testing, the JWT variant and what production needs

supertest sends requests straight into the Express app without starting a server. The helper imitates a browser on your own site behind an HTTPS proxy and extracts the session cookie from the response. We pass the cookie by hand instead of using an agent, so the test checks its attributes along the way.

test/helpers.ts
import type { Express } from "express";
import request from "supertest";
import { SESSION_COOKIE } from "../src/app.ts";

// Так выглядит запрос со своего сайта, прошедший через HTTPS-прокси
export const SAME_ORIGIN = { "X-Forwarded-Proto": "https", "Sec-Fetch-Site": "same-origin" };

export function sessionCookie(res: request.Response): string | undefined {
  const cookies = res.get("Set-Cookie") ?? [];
  return cookies.find((c) => c.startsWith(`${SESSION_COOKIE}=`))?.split(";")[0];
}

export async function login(app: Express, email: string, password: string) {
  const res = await request(app).post("/login").set(SAME_ORIGIN).send({ email, password });
  return { res, cookie: sessionCookie(res) };
}
Shared test helpers
test/auth.test.ts
import { randomBytes } from "node:crypto";
import request from "supertest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createApp } from "../src/app.ts";
import { createUser } from "../src/users.ts";
import { login, SAME_ORIGIN, sessionCookie } from "./helpers.ts";

const app = createApp({
  sessionSecret: randomBytes(32).toString("hex"),
  appOrigin: "https://app.example.com",
});
const EMAIL = "anna@example.com";
const PASSWORD = "long-and-unique-passphrase";

beforeAll(async () => {
  await createUser(EMAIL, PASSWORD, "manager");
});

afterEach(() => {
  vi.useRealTimers();
});

describe("вход по сессии", () => {
  it("неверный пароль: 401 и никакой cookie", async () => {
    const { res, cookie } = await login(app, EMAIL, "wrong-password");
    expect(res.status).toBe(401);
    expect(res.body).toEqual({ error: "invalid_credentials" });
    expect(cookie).toBeUndefined();
  });

  it("неизвестный email: тот же ответ, что и при неверном пароле", async () => {
    const { res } = await login(app, "nobody@example.com", PASSWORD);
    expect(res.status).toBe(401);
    expect(res.body).toEqual({ error: "invalid_credentials" });
  });

  it("без cookie защищённый маршрут отвечает 401", async () => {
    const res = await request(app).get("/me").set(SAME_ORIGIN);
    expect(res.status).toBe(401);
  });

  it("успешный вход ставит защищённую cookie и открывает /me", async () => {
    const { res, cookie } = await login(app, EMAIL, PASSWORD);
    expect(res.status).toBe(204);
    const setCookie = res.get("Set-Cookie")!.join("; ");
    expect(setCookie).toMatch(/^__Host-sid=/);
    expect(setCookie).toMatch(/; Path=\//);
    expect(setCookie).toMatch(/; HttpOnly/);
    expect(setCookie).toMatch(/; Secure/);
    expect(setCookie).toMatch(/; SameSite=Lax/);

    const me = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
    expect(me.status).toBe(200);
    expect(me.body).toMatchObject({ email: EMAIL, role: "manager" });
  });

  it("вход выдаёт новый ID сессии, старый перестаёт работать", async () => {
    const first = await login(app, EMAIL, PASSWORD);
    const res = await request(app)
      .post("/login").set(SAME_ORIGIN).set("Cookie", first.cookie!)
      .send({ email: EMAIL, password: PASSWORD });
    const second = sessionCookie(res);
    expect(second).toBeDefined();
    expect(second).not.toBe(first.cookie);

    const old = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", first.cookie!);
    expect(old.status).toBe(401);
  });

  it("после выхода старая cookie больше не работает", async () => {
    const { cookie } = await login(app, EMAIL, PASSWORD);
    const out = await request(app).post("/logout").set(SAME_ORIGIN).set("Cookie", cookie!);
    expect(out.status).toBe(204);

    const me = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
    expect(me.status).toBe(401);
  });

  it("POST с чужого сайта отклоняется, сессия остаётся", async () => {
    const { cookie } = await login(app, EMAIL, PASSWORD);
    const out = await request(app)
      .post("/logout")
      .set({ ...SAME_ORIGIN, "Sec-Fetch-Site": "cross-site" })
      .set("Cookie", cookie!);
    expect(out.status).toBe(403);

    const me = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
    expect(me.status).toBe(200);
  });

  it("сессия заканчивается через 8 часов даже при активности", async () => {
    vi.useFakeTimers({ toFake: ["Date"] });
    const start = Date.now();
    const { cookie } = await login(app, EMAIL, PASSWORD);
    const step = 20 * 60 * 1000; // запрос каждые 20 минут — idle-тайм-аут не срабатывает

    for (let i = 1; i <= 24; i++) {
      vi.setSystemTime(start + i * step);
      const me = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
      expect(me.status).toBe(200);
    }
    vi.setSystemTime(start + 25 * step); // 8 ч 20 мин после входа
    const late = await request(app).get("/me").set(SAME_ORIGIN).set("Cookie", cookie!);
    expect(late.status).toBe(401);
  });
});
Wrong password, no cookie, cookie attributes, new ID after login, logout, CSRF and the absolute timeout

We verified every test the other way round: we removed session.destroy from logout, regenerate from login, the CSRF check, the absolute timeout and the Secure flag — and each time exactly the test responsible for it failed. A test that doesn’t fail on broken code protects nothing.

The JWT variant is for a mobile app or another service that sends the token in the Authorization header. The module below issues a 10-minute access token and verifies it by the rules from the JWT section. On failure it answers 401 with a WWW-Authenticate header, as RFC 9110 requires (the Bearer scheme comes from RFC 6750). The IANA registry has no scheme for cookie-based login, so our session 401 responses go without that header — a deliberate deviation worth documenting in your API docs.

src/tokens.ts
import type { NextFunction, Request, Response } from "express";
import { errors, jwtVerify, SignJWT, type JWTPayload } from "jose";
import { randomUUID } from "node:crypto";

declare global {
  namespace Express {
    interface Locals {
      tokenSubject?: string;
    }
  }
}

export const ALG = "HS256";
export const ISSUER = "https://api.example.com";
export const AUDIENCE = "https://api.example.com/v1";
export const TYP = "app-access+jwt"; // явный тип токена (RFC 8725, §3.11)

export function loadJwtSecret(hex: string | undefined): Uint8Array {
  // HS256: ключ не короче 256 бит из криптостойкого генератора, а не пароль
  if (!hex || !/^(?:[0-9a-f]{2}){32,}$/i.test(hex)) throw new Error("JWT_SECRET must be at least 32 random bytes in hex");
  return Buffer.from(hex, "hex");
}

export async function issueAccessToken(userId: string, secret: Uint8Array): Promise<string> {
  return new SignJWT()
    .setProtectedHeader({ alg: ALG, typ: TYP })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setIssuedAt()
    .setExpirationTime("10m") // короткая жизнь: отозвать JWT до exp сложно
    .setJti(randomUUID())
    .sign(secret);
}

export async function verifyAccessToken(token: string, secret: Uint8Array): Promise<JWTPayload> {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: [ALG], // только ожидаемый алгоритм
    issuer: ISSUER,
    audience: AUDIENCE,
    typ: TYP,
    requiredClaims: ["sub", "exp", "jti"],
  });
  return payload;
}

export function requireBearer(secret: Uint8Array) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const match = /^Bearer (\S+)$/i.exec(req.get("Authorization") ?? "");
    if (!match) {
      res.set("WWW-Authenticate", "Bearer").status(401).json({ error: "unauthenticated" });
      return;
    }
    try {
      const payload = await verifyAccessToken(match[1], secret);
      res.locals.tokenSubject = payload.sub;
      next();
    } catch (err) {
      if (!(err instanceof errors.JOSEError)) throw err;
      res.set("WWW-Authenticate", 'Bearer error="invalid_token"').status(401).json({ error: "invalid_token" });
    }
  };
}
JWT with the jose library: issuing, strict verification and a Bearer middleware
test/tokens.test.ts
import express from "express";
import { errors, SignJWT, UnsecuredJWT } from "jose";
import { randomBytes } from "node:crypto";
import request from "supertest";
import { describe, expect, it } from "vitest";
import {
  ALG, AUDIENCE, ISSUER, TYP,
  issueAccessToken, loadJwtSecret, requireBearer, verifyAccessToken,
} from "../src/tokens.ts";

const secret = loadJwtSecret(randomBytes(32).toString("hex"));

const app = express();
app.get("/api/me", requireBearer(secret), (req, res) => {
  res.json({ sub: res.locals.tokenSubject });
});

describe("JWT access-токен", () => {
  it("валидный токен открывает маршрут", async () => {
    const token = await issueAccessToken("user-42", secret);
    const res = await request(app).get("/api/me").set("Authorization", `Bearer ${token}`);
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ sub: "user-42" });
  });

  it("без токена: 401 и заголовок WWW-Authenticate", async () => {
    const res = await request(app).get("/api/me");
    expect(res.status).toBe(401);
    expect(res.get("WWW-Authenticate")).toBe("Bearer");
  });

  it("подменённый payload не проходит проверку подписи", async () => {
    const [header, , signature] = (await issueAccessToken("user-42", secret)).split(".");
    const payload = Buffer.from(JSON.stringify({ sub: "admin" })).toString("base64url");
    const forged = `${header}.${payload}.${signature}`;

    await expect(verifyAccessToken(forged, secret)).rejects.toThrow(errors.JWSSignatureVerificationFailed);
    const res = await request(app).get("/api/me").set("Authorization", `Bearer ${forged}`);
    expect(res.status).toBe(401);
    expect(res.get("WWW-Authenticate")).toBe('Bearer error="invalid_token"');
  });

  it('токен с alg "none" отклоняется', async () => {
    const unsigned = new UnsecuredJWT({})
      .setSubject("admin").setIssuer(ISSUER).setAudience(AUDIENCE).setExpirationTime("10m").encode();
    await expect(verifyAccessToken(unsigned, secret)).rejects.toThrow(errors.JOSEError);
  });

  it("просроченный токен отклоняется", async () => {
    const hourAgo = Math.floor(Date.now() / 1000) - 3600;
    const expired = await new SignJWT()
      .setProtectedHeader({ alg: ALG, typ: TYP })
      .setSubject("user-42").setIssuer(ISSUER).setAudience(AUDIENCE).setJti("t-1")
      .setIssuedAt(hourAgo).setExpirationTime(hourAgo + 600)
      .sign(secret);
    await expect(verifyAccessToken(expired, secret)).rejects.toThrow(errors.JWTExpired);
  });

  it("токен для другого сервиса (aud) отклоняется", async () => {
    const foreign = await new SignJWT()
      .setProtectedHeader({ alg: ALG, typ: TYP })
      .setSubject("user-42").setIssuer(ISSUER).setAudience("https://billing.example.com").setJti("t-2")
      .setIssuedAt().setExpirationTime("10m")
      .sign(secret);
    await expect(verifyAccessToken(foreign, secret)).rejects.toThrow(errors.JWTClaimValidationFailed);
  });
});
A forged signature, alg none, an expired token and a foreign aud are rejected
terminal
$ npx vitest run test/
 RUN  v5.0.3 ~/auth-demo

 Test Files  2 passed (2)
      Tests  14 passed (14)
All 14 tests pass

What is missing for production, based on OWASP and the Express documentation:

  • Login throttling: the Express docs suggest counting failures per username + IP and per IP over a long period (for example with rate-limiter-flexible); OWASP suggests lockout tied to the account, not just the IP
  • A session store in Redis or a database instead of MemoryStore; compatible stores are listed in the express-session README
  • Session secret rotation: secret accepts an array, the new key goes first and the old ones still verify issued cookies
  • HTTPS end to end and a trust proxy value that matches the real proxy setup
  • Password rules: per OWASP, citing NIST SP 800-63B, passwords shorter than 15 characters without MFA are weak, and the maximum length should be at least 64 characters
  • MFA and re-authentication before sensitive actions: changing the email, password or payment details
  • A new session ID on every privilege change, not only at login
  • An audit log of logins, failures, logouts and lockouts — without passwords or session IDs
How this was verified5 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.