Security & TypeScript · Oct 2026 · 12 min read

Extracting Auth From a Real App Into a Tested TypeScript Library

I extracted the authentication from one of my own apps, AssetOps, into a standalone TypeScript library. Reading it like a reviewer turned up five real weaknesses, each proved with a small runnable script: a timing leak, sessions that survive a password reset, a 72-byte password limit, a reset-token race and a per-process rate limiter. The library is tested against a real Postgres and shipped through CI, a container and a packed artifact.

Why Pull Auth Out of an App at All

Most apps I build end up with the same 40 lines: hash a password, sign a session, check a role. They get written once, work, and are never looked at again. That is exactly why they are worth lifting out. Code that nobody revisits hides its weaknesses, and code welded to one framework and one database cannot be tested on its own.

So I picked one of my own apps, AssetOps (a Next.js and Prisma asset-management tool), and extracted its authentication into a standalone TypeScript library, authcore-ts. The goal was a project I could defend line by line: strict types, a real database in the tests, a pipeline that builds and ships it, and every claim backed by something you can run.

This article covers what was in the original, the five weaknesses I found by reading it like a reviewer, how I proved each one with a small script instead of just asserting it, and how the library is tested and delivered.

What the Original Looked Like

AssetOps keeps its core auth in one 40-line file, src/lib/auth.ts, plus a role matrix in rbac.ts (six roles, 22 permissions) and four route files for sign-in, sign-up, forgot password and reset. In short:

That is a sensible design, and it comes with tests. It is also tied to Next.js cookies() and to Prisma, so none of it can be reused or exercised in isolation.

One caveat before the findings. The scripts below re-create the original's logic in a few lines; they do not run AssetOps itself. They show that the pattern behaves this way, not that a particular deployment is exploitable.

Five Weaknesses, Each With Evidence

1. Response time reveals which emails have accounts

The sign-in route only checks the password when it found a user:

const u = await db.user.findUnique({ where: { email: email.toLowerCase() } });
const ok = u && u.active && (await verifyPassword(password, u.passwordHash));

The error message is identical either way, which is good. But bcrypt takes real time and the unknown-user path skips it. I measured both paths at the original's cost of 10, taking the median of 25 attempts each:

Median of 25 wrong-password attempts each, bcrypt cost 10:

original  known email:  112.4 ms   unknown email:    0.0 ms   ratio: 37455.2x
authcore  known email:  112.2 ms   unknown email:  112.2 ms   ratio: 1.0x

The huge ratio is only because the unknown-email path rounds to zero: the original answers an unknown email effectively instantly and a known one in about 112 ms. Anyone can tell the two apart from the outside. The fix is to always spend one verification: when no user matches, verifyAbsent runs bcrypt against a throwaway hash made at the same cost.

2. A stolen session survives a password reset

The reset route updates the password hash and marks the token used. It never touches the session. Because the session token holds only the user id, and getSession checks only that the user is active, every token issued before the reset stays valid for its full seven days. Resetting a password is exactly what someone does after suspecting a compromise, and it leaves the attacker signed in.

1. A thief steals the session token. 2. The owner resets their password.

original : token valid before reset: true   after reset: true   <- thief is still signed in
authcore : token valid before reset: true   after reset: false  <- revoked

The fix is a per-user sessionVersion. The token carries the version it was issued under, verification compares it with the user's current version, and resetting the password bumps it in the same statement that changes the hash. One number invalidates every session at once, and there is no token blocklist to maintain.

3. bcrypt silently ignores everything after 72 bytes

bcrypt reads only the first 72 bytes of its input. The original's reset route accepts passwords up to 200 characters, so two different long passwords that share a 72-byte prefix hash identically:

two different passwords, 91 and 93 bytes, identical for the first 72 bytes

plain bcrypt   : hash(first), then check(second) -> true   <- accepted the wrong password
authcore-ts    : hash(first), then check(second) -> false  <- rejected
authcore-ts    : hash(first), then check(first)  -> true

New hashes bcrypt the base64 of the password's SHA-256 digest, which is always 44 characters, and carry a $bcrypt-sha256$v1$ prefix (the same idea Django uses). Existing plain-bcrypt hashes still verify and are upgraded the next time that user signs in, so no one has to reset their password.

4. A single-use token can be used more than once

The reset route reads the token, checks it is unused and unexpired, and only then writes. Between the read and the write there is a gap, and two requests arriving together can both pass the check. I reproduced the pattern against a real Postgres 16, sending 40 simultaneous requests for the same token, five rounds each:

40 simultaneous requests redeem ONE reset token, 5 rounds. Successful redemptions per round:

naive  (select, then update)     1, 40, 40, 40, 39
atomic (one conditional UPDATE)  1, 1, 1, 1, 1

The naive version let one token be redeemed 39 or 40 times in four of the five rounds. Note the first round returned 1: a race is not guaranteed to fire, which is why a single passing test proves nothing here. The fix lets the database decide, with one conditional statement:

update auth_password_resets
   set used_at = $2
 where token_hash = $1 and used_at is null and expires_at > $2
returning user_id

Whichever request reaches the row first gets a row back; every other request matches nothing.

5. The rate limiter lives in one process

The original's limiter is a Map with a comment saying to swap it for Redis in multi-instance deployments. Run two instances and each keeps its own counter, so the limit silently doubles. In the library the limiter is an interface, with an in-memory version for development and a PostgresRateLimiter that does the whole check in one atomic upsert, so instances share one count. The contract test sends 30 concurrent requests with a limit of 5 and requires exactly 5 to be allowed.

Smaller things

In the originalIn authcore-ts
Failed sign-ins logged the raw email addressAudit events carry no email
JWT verified without naming the algorithmOnly HS256 is accepted; unsigned and other-algorithm tokens are rejected in tests
Any error while checking a session counted as "signed out"Only token problems do; a database outage is raised as an error instead of quietly logging everyone out
The session secret length was checked only when issuingChecked once at startup, so a weak secret fails fast

Shaping the Library

The extraction rule was simple: the core knows nothing about Next.js, Prisma or Postgres. Everything it needs from the outside is a small interface.

export interface UserStore<U extends AuthUser = AuthUser> {
  findByEmail(email: string): Promise<U | null>;
  findById(id: string): Promise<U | null>;
  updatePasswordHash(userId: string, passwordHash: string): Promise<void>;
  setPasswordAndRevokeSessions(userId: string, passwordHash: string): Promise<void>;
  revokeSessions(userId: string): Promise<void>;
}

ResetTokenStore and RateLimiter are equally small. The library ships an in-memory implementation of each and Postgres implementations that take any object with a query(text, values) method, so it has no database dependency. About 800 lines of source in 13 files, with bcryptjs and jose as the only runtime dependencies.

Roles and permissions are typed from the matrix itself, so a typo is a compile error rather than a silent false:

const rbac = defineRbac({
  ADMIN: ["asset:read", "asset:write", "user:manage"],
  TECHNICIAN: ["asset:read", "workorder:update"],
} as const);

rbac.can("ADMIN", "user:manage");   // true
// @ts-expect-error "asset:wrte" is not a permission in the matrix
rbac.can("ADMIN", "asset:wrte");

That @ts-expect-error line is part of the test suite, so if the types ever loosen, the build fails. The compiler runs with strict, noUncheckedIndexedAccess and exactOptionalPropertyTypes, and ESLint runs the type-aware strict rule set.

The sign-in flow, end to end:

POST /sign-in
  -> assertSameOrigin          reject cross-site requests (needs a matching Origin)
  -> rate limiter              8 attempts per minute per address and email
  -> findByEmail
  -> verify password           or burn the same CPU if there is no such user
  -> upgrade the hash          if it is plain bcrypt or a lower cost
  -> issue session             JWT carrying the user's sessionVersion
GET /me
  -> verify token (HS256 only) -> load user -> check active and sessionVersion

How It Is Tested

The suite has 70 tests across 10 files, and the coverage report reads 100% of statements, functions and lines and 96% of branches. Numbers alone prove little, so here is what the tests are built around.

Unit tests cover the behaviour that matters, not just the happy path. For example: forged, unsigned and wrong-algorithm tokens are rejected; the 72-byte test first shows plain bcrypt accepting the wrong password, then shows the library refusing it; an oversized password never reaches the database; a weak new password does not burn the reset token; and an earlier reset link stops working when a newer one is requested.

One contract suite runs against every store. The same behavioural assertions run against the in-memory stores and against a real Postgres. That is what shows the two are interchangeable, and it includes the concurrency cases: 25 simultaneous attempts to consume one token must produce exactly one winner, and 30 simultaneous rate-limit requests must allow exactly the limit.

A test that cannot fail proves nothing. That is why the race demo above exists. The naive version passes a quick single-request check and fails under concurrency, which shows the concurrency test can actually fail.

An end-to-end smoke test over real HTTP. The example server wires the library to Postgres, and a shell script drives it with curl: wrong and unknown credentials return the same 401, a cross-origin sign-in is blocked, the session cookie is HttpOnly, an admin can reach an admin route and a viewer gets a 403, and a password reset kills the old session. 23 checks in all.

Delivery: CI, a Container and a Packed Artifact

Pushing to main runs five CI jobs. These are the real timings from the first run:

JobWhat it checksTime
Test (Node 20)typecheck, lint, 70 tests with coverage gate, against a postgres:16 service container35 s
Test (Node 22)the same on Node 2246 s
Package is validbuild, then publint and "are the types wrong" for ESM and CJS consumers19 s
Docker image and smoke testbuild the image, start the app and Postgres with Compose, run the 23 checks, confirm the container is not root46 s
Dependency auditnpm audit on runtime dependencies6 s

CodeQL runs separately and also passed, and Dependabot keeps npm, GitHub Actions and Docker dependencies current.

Two choices are worth explaining. First, the Dockerfile builds the library, runs npm pack, and installs the resulting tarball into a minimal runtime image. The container therefore runs the artifact exactly as a consumer would receive it, not the source tree, which catches packaging mistakes that a normal build hides. Second, there is a release workflow with npm provenance, but it is switched off until a repository variable is set, so tagging a version does nothing until I decide to publish.

As a final check I installed it from GitHub into an empty project and imported it from both an ES module and a CommonJS file. Both worked.

What Went Wrong Along the Way

A few honest notes, because a tidy log hides the useful part.

Limits and What Comes Next

Try It

git clone https://github.com/raphgm/authcore-ts && cd authcore-ts
npm ci
npm test                      # unit tests with in-memory stores
npm run demo:timing           # the timing measurement
npm run demo:truncation       # the 72-byte measurement
npm run demo:sessions         # session survives a reset, or not
docker compose up --build     # the example server with Postgres, then:
./scripts/smoke.sh            # 23 checks over real HTTP

If you have a small piece of code you wrote once and never looked at again, extracting it is one of the quickest ways to find out what it actually does. Mine did not do quite what I assumed, and now there is a test for each place where it did not.

GitHub Repository: authcore-ts, with the library, the tests, the demos, the example server and the CI pipeline.

Tested on Node 20 and 22 with TypeScript 5, bcryptjs 3, jose 6 and Postgres 16, as of October 2026.

TypeScript · Authentication · Security · PostgreSQL · GitHub Actions · Docker