A small, self-contained library for authentication and authorization, designed around simplicity, ease of use, and security.
This library is purposely not published to any npm registry.
Instead, copy auth.ts into your project, read it, and extend it to your needs. It's a single file with one dependency (jsonwebtoken), everything else is Node.js built-ins.
The library does not force you to use any specific database or ORM: bring your own way to store and retrieve data by passing an adapter to the constructor.
- Email + password authentication, register, login, logout
- JWT access tokens, short-lived, used for authenticated requests
- Refresh tokens, long-lived, stored hashed (SHA-256) in your database as sessions, used to mint new access tokens
- Password reset flow, single-use, short-lived reset tokens; resetting the password invalidates all previous sessions
- Session management, sessions live in your database, so you can list a user's devices and remotely close sessions on other devices
- Custom token payloads, enrich tokens with your own claims (e.g. roles), fully typed
- Pluggable rate limiting and email validation, via optional adapter hooks
- Errors as values, every method returns a
[result, error]tuple with typed string error codes; notry/catchneeded - Secure password hashing,
scryptwith a per-user random salt and timing-safe comparison - Debug logging, opt-in, tagged per operation
- Tested, see
auth.test.ts, runs with the built-in Node test runner
import { Auth } from "./auth"
// Optional: add additional payload to the tokens.
type AdditionalTokenPayload = {
role: "user" | "admin"
}
const auth = new Auth<AdditionalTokenPayload>({
jwtSecret: process.env.JWT_SECRET!,
adapter: {
findUser: async ({ email }) => {
return await db.user.findUnique({ where: { email } })
},
createUser: async ({ email, password }) => {
return await db.user.create({ data: { email, password } })
},
updateUser: async (userId, data) => {
await db.user.update({ where: { id: userId }, data })
},
createSession: async (data) => {
return await db.authSession.create({ data })
},
findSession: async ({ refreshTokenHash }) => {
return await db.authSession.findUnique({ where: { refreshTokenHash } })
},
deleteSession: async ({ userId, refreshTokenHash }) => {
await db.authSession.delete({ where: { userId, refreshTokenHash } })
},
deleteSessions: async ({ userId }) => {
await db.authSession.deleteMany({ where: { userId } })
},
// Required only when an additional token payload type is provided.
enrichTokenPayload: async ({ userId }) => {
const user = await db.user.findUniqueOrThrow({ where: { id: userId } })
return { role: user.role }
}
},
// Optional fields below (defaults shown).
accessTokenExpiresInMs: 1000 * 60 * 60, // 1 hour
refreshTokenExpiresInMs: 1000 * 60 * 60 * 24 * 14, // 14 days
passwordResetTokenExpiresInMs: 1000 * 60 * 10, // 10 minutes
isDebugLoggingEnabled: false
})
// ... in your endpoint handler
async function handleLogin(request: Request) {
const { email, password } = await request.json()
const [result, error] = await auth.login({ email, password })
if (error) {
return new Response(JSON.stringify({ error }), { status: 400 })
}
return new Response(JSON.stringify(result), { status: 200 })
}- A user registers (or logs in) with their email and password.
- A new session is created, returning two JWTs:
- an access token (short-lived), sent with authenticated requests and verified with
getTokenPayload - a refresh token (long-lived), used to mint new access tokens via
refreshAccessToken
- an access token (short-lived), sent with authenticated requests and verified with
- Only the SHA-256 hash of the refresh token is stored in your database, so a leaked database doesn't leak usable tokens. Deleting the session row revokes the refresh token.
- When the user forgets their password,
forgotPasswordgenerates a short-lived reset token to send to the user's email.resetPasswordconsumes it and, by default, closes all of the user's previous sessions.
Every method returns a tuple instead of throwing:
const [result, error] = await auth.register({ email, password })
if (error) {
// `error` is a typed string union, e.g.
// "INVALID_EMAIL" | "TOO_MANY_REQUESTS" | "USER_ALREADY_EXISTS" | ...
}Exactly one of the two is defined, and the error codes are inferred per method, so you can exhaustively handle them with a switch.
| Method | Purpose | Notable errors |
|---|---|---|
register({ email, password, ip? }) |
Create a user and a first session; returns { userId, session, accessToken, refreshToken } |
INVALID_EMAIL, TOO_MANY_REQUESTS, USER_ALREADY_EXISTS |
login({ email, password, ip? }) |
Verify credentials and create a session; same return shape as register |
INVALID_CREDENTIALS, TOO_MANY_REQUESTS |
refreshAccessToken({ refreshToken }) |
Mint a new access token from a valid, non-revoked refresh token | INVALID_TOKEN, SESSION_NOT_FOUND, SESSION_EXPIRED |
closeSession({ refreshToken, refreshTokenHash? }) |
Logout. Pass refreshTokenHash to close a different session owned by the same user (e.g. "logout other devices") |
INVALID_TOKEN, SESSION_NOT_FOUND |
forgotPassword({ email }) |
Generate a password reset token to send to the user | INVALID_EMAIL, USER_NOT_FOUND |
resetPassword({ passwordResetToken, newPassword, closePreviousSessions? }) |
Set a new password; closes all previous sessions unless closePreviousSessions: false |
INVALID_TOKEN, FAILED_TO_UPDATE_USER |
getTokenPayload(token, typeOrTypes) |
Verify a JWT and check its type ("access", "refresh", or "password-reset"); use this to authenticate requests |
INVALID_TOKEN, INVALID_TOKEN_TYPE |
Note
forgotPassword returns USER_NOT_FOUND, but your API should respond identically whether or not the email exists, otherwise an attacker can probe which emails are registered.
Each login creates a session row in your database, keyed by the refresh token hash. That makes a "manage my devices" feature straightforward:
-
List devices, query your session table by
userId(the optionalagentfield is there to label each device). -
Close a session on another device, call
closeSessionwith the current device's refresh token plus therefreshTokenHashof the session to close. The refresh token proves the caller owns the account; the hash selects which session to revoke:const [, error] = await auth.closeSession({ refreshToken: currentDeviceRefreshToken, refreshTokenHash: otherDeviceSessionHash // from your session list })
-
Close all sessions, call your adapter's
deleteSessions({ userId })directly, or reset the password (which does it by default).
Once a session row is deleted, its refresh token can no longer mint access tokens, so the device is logged out as soon as its current access token expires.
The adapter is how the library talks to your storage layer. Required methods:
| Method | Used for |
|---|---|
findUser({ email }) |
Login, registration duplicate check, password reset |
createUser({ email, password }) |
Registration (password arrives already hashed) |
updateUser(userId, data) |
Password reset |
createSession(session) |
Login and registration |
findSession({ refreshTokenHash }) |
Access token refresh |
deleteSession({ userId, refreshTokenHash }) |
Logout |
deleteSessions({ userId }) |
Invalidating all sessions after a password reset |
Optional hooks:
validateEmail(email), custom email validation, runs in addition to the built-in regex checkisLoginRateLimited({ ip?, email })/isRegisterRateLimited({ ip?, email }), returntrueto reject the attempt withTOO_MANY_REQUESTSenrichTokenPayload({ userId }), required when you pass an additional token payload type toAuth<...>; its return value is merged into every token (the reservedtypeanduserIdclaims cannot be overridden)
The library has no Prisma dependency, this is just the shape your storage needs:
model User {
id String @id @default(cuid())
email String @unique
password String
verified Boolean @default(false)
authSessions AuthSession[]
}
model AuthSession {
refreshTokenHash String @id
userId String
user User @relation(fields: [userId], references: [id])
agent String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}- Passwords are hashed with
scryptand a random 16-byte salt, stored asscrypt$<salt>$<hash>; comparisons usecrypto.timingSafeEqual. - Refresh tokens are never stored in plaintext, only their SHA-256 hash.
- Login failures return the same
INVALID_CREDENTIALSerror whether the user doesn't exist or the password is wrong. - Resetting a password closes all existing sessions by default.
- Token expirations are enforced both by JWT verification and, for sessions, by the
expiresAtstored in your database.
npm testTests live in auth.test.ts and use the built-in Node.js test runner with an in-memory adapter, a useful reference for writing your own adapter.