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:
- passwords are hashed with bcrypt at cost 10;
- a session is a 7-day HS256 JWT in an HttpOnly cookie, holding just the user id;
- on each request the session is verified and the user is loaded to check they are still active;
- sign-in is rate limited by address and email, checks the request origin, and gives the same message for a wrong password and an unknown user;
- password reset uses single-use tokens, stored hashed with an expiry.
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 original | In authcore-ts |
|---|---|
| Failed sign-ins logged the raw email address | Audit events carry no email |
| JWT verified without naming the algorithm | Only 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 issuing | Checked 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:
| Job | What it checks | Time |
|---|---|---|
| Test (Node 20) | typecheck, lint, 70 tests with coverage gate, against a postgres:16 service container | 35 s |
| Test (Node 22) | the same on Node 22 | 46 s |
| Package is valid | build, then publint and "are the types wrong" for ESM and CJS consumers | 19 s |
| Docker image and smoke test | build the image, start the app and Postgres with Compose, run the 23 checks, confirm the container is not root | 46 s |
| Dependency audit | npm audit on runtime dependencies | 6 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.
- My smoke test failed 11 of 23 checks on its first run, and the server was fine. macOS ships bash 3.2, which mishandled the nested escaped quotes I used to build JSON bodies, so the requests were split in two. I rewrote the script to build bodies with small
printfhelpers. The lesson is to run shell tests on the oldest shell your users have, not only in CI. - I could not build the Docker image on my own machine. Docker Desktop was not installed, and the alternative had filled my disk before, so the image was first built by GitHub Actions. It passed, but I want to be clear that CI was the first place it ever ran.
- For the database tests I ran Postgres natively (a throwaway instance from Homebrew, on a spare port) rather than in a container. CI uses the official image, so both are exercised.
- ESLint's strict rules rejected a handful of patterns in my first draft, such as returning a void expression. They were small, but they are the kind of thing that never gets caught without a type-aware linter.
Limits and What Comes Next
- AssetOps does not use the library yet. The extraction is done and proven, but I have not switched AssetOps over to it. That is the obvious next step, and AssetOps' own auth tests would then act as a regression check on the library.
- Sessions are stateless. Signing out clears the cookie but does not invalidate the token. To sign a user out everywhere, call
revokeSessions. - The in-memory rate limiter is for one process. Use the Postgres limiter, or write a Redis one against the same interface.
- You send the reset email. The library returns the token; responding identically for known and unknown addresses, and sending the email in the background, is up to the caller.
- It is not on npm. It installs from GitHub, and the publish workflow is ready but disabled.
- There is no OAuth, magic links or two-factor authentication. It covers password sessions and access control, nothing more.
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