Docs / Packages / @zudojs/auth
v1.3.0

@zudojs/auth

Authentication and authorization services — JWT tokens (access + refresh), password hashing (scrypt), session management, RBAC, brute-force lockout, and auth utilities.

AUTH JWT RBAC SESSIONS

OVERVIEW

@zudojs/auth answers two questions for your application: who is this request from, and is that person allowed to do this. It gives you password hashing, JWT access and refresh tokens, server-side sessions, brute-force lockout, and a single AuthService that ties them together.

You bring the database. The package never reads or writes user rows itself — you hand it three small functions (find a user by login identifier, find a user by id, check a password) and it does the rest.

Tokens are signed with HMAC SHA-256 from Node's built-in node:crypto; password hashing (scrypt) is delegated to @zudojs/crypto. There are no third-party crypto dependencies.

Use it when
  • Users log in with an email/username and a password.
  • You need to protect API routes with bearer tokens.
  • You need "log out everywhere" to actually work.
  • Different users may do different things (roles).
Look elsewhere when
OAuth2 moved out

Social and third-party sign-in used to live here. It now has its own package, @zudojs/auth-oauth. Nothing named OAuthProvider, OAuthConfig or AuthStrategy is exported from @zudojs/auth any more. See @zudojs/auth-oauth.

INSTALLATION

Install the package. Its dependencies, @zudojs/constants, @zudojs/crypto, @zudojs/errors, @zudojs/permissions and @zudojs/types, are installed with it.

# npm $ npm install @zudojs/auth # pnpm $ pnpm add @zudojs/auth

Node.js 24 or newer is required, because the package uses the modern node:crypto scrypt API.

Source of truth

These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.

THE WORDS, IN PLAIN ENGLISH

Five terms show up on every page below. Here is what each one means before you write any code.

Authentication vs authorization

Authentication is proving who you are. Typing your email and password is authentication: the server checks the password and concludes "this really is Alice".

Authorization is deciding what you may do. "Alice is logged in, but may Alice delete this post?" is authorization. They are separate steps, and this package does both: login() authenticates, checkAccess() authorizes.

What a JWT is

A JWT ("JSON Web Token") is a string the server hands you after you log in. You send it back on every later request instead of your password.

It has three dot-separated parts: a header, a payload of facts (the user id, the roles, an expiry time), and a signature. The signature is computed with a secret only the server knows, so the server can tell whether a token was tampered with.

The payload is only encoded, not encrypted. Anyone holding the token can read what is inside it. Never put a secret in a token.

What a session is

A session is a record on the server saying "this login is still alive". It has an id, the user it belongs to, and an expiry time.

Sessions matter because a JWT, on its own, cannot be taken back — it stays valid until it expires. This package stamps the session id into every token it issues, and refuses tokens whose session is gone. That is what makes logout instant.

What hashing a password means

Hashing runs a password through a one-way function. "hunter2" becomes a long jumble, and there is no way back from the jumble to the password.

You store the jumble. At login you hash what the user typed and compare the two jumbles. If your database ever leaks, the attacker gets jumbles, not passwords — and people reuse passwords everywhere, so that difference is everything.

What RBAC is

RBAC means "role-based access control". Instead of listing what each individual user may do, you give users roles ("admin", "editor", "viewer") and give each role a set of permissions ("posts:write").

A permission here is a plain string in the shape "resource:action". Roles and matching are handled by @zudojs/permissions; this package just asks it questions.

QUICK START

This is a complete, runnable file. It stores one user in memory, logs her in, verifies her access token, and logs her out.

import { createAuthService, createMemorySessionStore, hashPassword, verifyPassword, toUserId, type AuthUser, } from "@zudojs/auth"; // Secrets come from the environment; a missing one stops the app at startup. function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is not set`); return value; } // 1. A user record. In a real app this row comes from your database. const passwordHash = await hashPassword("correct horse battery staple"); const alice: AuthUser = { id: toUserId("user-123"), email: "alice@example.com", roles: ["editor"], active: true, createdAt: new Date(), }; // 2. Wire the service. Every function below is one you supply. const auth = createAuthService({ token: { accessSecret: requireEnv("JWT_ACCESS_SECRET"), refreshSecret: requireEnv("JWT_REFRESH_SECRET"), }, sessionStore: createMemorySessionStore(), sessionTtlSeconds: 3600, findUser: async (identifier) => (identifier === alice.email ? alice : null), findUserById: async (id) => (id === alice.id ? alice : null), verifyPassword: async (userId, password) => userId === alice.id ? verifyPassword(password, passwordHash) : false, }); // 3. Log in. const { user, tokens, sessionId } = await auth.login({ identifier: "alice@example.com", password: "correct horse battery staple", }); console.log(user.email, tokens.tokenType, tokens.expiresIn); // alice@example.com Bearer 900 // 4. Check the access token on a later request. const payload = await auth.verifyToken(tokens.accessToken); console.log(payload.sub, payload.roles); // user-123 [ 'editor' ] // 5. Log out. Both tokens stop working immediately. await auth.logout(sessionId, tokens.refreshToken); await auth.verifyToken(tokens.accessToken); // throws SessionExpiredError

What you should see: the two console.log lines above, then an uncaught SessionExpiredError from the last line — which is the point of the example.

login() normalises the identifier

Before findUser sees it, the identifier is NFKC-folded and trimmed, and lower-cased when it is an email address; a username keeps its case. So logging in with " Alice@Example.COM " finds the user above. Store identifiers through the same exported normalizeLoginIdentifier() at registration so both sides agree. Pass normalizeIdentifier: false to receive the raw string, or your own function. Changed in 1.3.0: earlier versions passed the raw string, so a case or whitespace variant missed the lookup.

import { normalizeLoginIdentifier } from "@zudojs/auth"; // At registration, store the identifier the way login() will look it up. console.log(normalizeLoginIdentifier(" Alice@Example.COM ")); // "alice@example.com" console.log(normalizeLoginIdentifier(" BobTheBuilder ")); // "BobTheBuilder" — usernames keep their case
Never hard-code secrets

The example reads both signing secrets from the environment and has no fallback, so set them before running it, e.g. export JWT_ACCESS_SECRET=$(openssl rand -hex 32) and the same for JWT_REFRESH_SECRET. Each must be at least 32 bytes, and the two must differ. createAuthService() checks them when it is constructed, not on first use: a missing, short or shared secret throws AuthConfigurationError (ERR_CONFIGURATION_INVALID) at startup. Never write ?? "some-default" after process.env: a secret that is in your source code is not a secret.

PASSWORDS

hashPassword() turns a password into a storable string. verifyPassword() checks a typed password against a stored one. You store only the output of the first.

The algorithm is scrypt, which is deliberately slow and memory-hungry, so guessing millions of passwords costs an attacker real time and RAM.

async function hashPassword( password: string, saltLength?: number, // bytes, default 32, must be 16..64 ): Promise<string> async function verifyPassword( password: string, hashedPassword: string, ): Promise<boolean>

Register a user, then check the password twice — once right, once wrong.

import { hashPassword, verifyPassword, needsRehash } from "@zudojs/auth"; const stored = await hashPassword("hunter2-but-longer"); console.log(stored.split("$").slice(0, 5).join("$")); // v1$scrypt$16384$8$5 (format version, algorithm and cost parameters, then <salt>.<hash>) console.log(await verifyPassword("hunter2-but-longer", stored)); // true console.log(await verifyPassword("wrong", stored)); // false console.log(needsRehash(stored)); // false

Hashing the same password twice gives two different strings, because each hash gets a fresh random salt mixed in. That is expected — verifyPassword() reads the salt back out of the stored string.

hashPassword returns a @zudojs/crypto hash, v1$scrypt$16384$8$5$<salt>.<hash>. Earlier scrypt$… hashes (all parameter sets and the param-less format) still verify, and needsRehash() returns true for every hash that is not a current-parameter crypto scrypt hash. A hash made by @zudojs/crypto's own hashPassword() with its defaults (same N, r and p; 16-byte salt, 32-byte key) counts as current and returns false; before 1.3.0 it was flagged on every login. Call it right after a successful login, while you still hold the plain password, and re-store a fresh hash if it says so. hashPassword("") throws AuthError (INVALID_INPUT).

verifyPassword never throws

Garbage input — a non-string, an unparseable hash, a password over MAX_PASSWORD_BYTES (1024) — comes back as false, not an exception. On a login route "false" is the right answer for all of those.

generateRandomToken(length?) gives you a random hex string for password-reset links and similar one-off secrets. The length is in bytes (16 to 1024, default 32), so the default returns 64 hex characters.

JWT TOKENS

Logging in produces a pair of tokens. The access token is short-lived (15 minutes by default) and travels on every request. The refresh token is long-lived (7 days) and is used only to obtain a new access token.

Splitting them limits the damage of a stolen access token to minutes, without forcing the user to log in every quarter hour.

function createTokenPair( userId: UserId, config: TokenConfig, options?: { roles?: readonly string[]; sessionId?: SessionId }, ): TokenPair function verifyAccessToken(token: JwtToken, config: TokenConfig): TokenVerificationResult function verifyRefreshToken(token: JwtToken, config: TokenConfig): TokenVerificationResult

Mint a pair by hand and verify it. Note that verification returns a result object rather than throwing.

import { createTokenPair, verifyAccessToken, toUserId, type TokenConfig, } from "@zudojs/auth"; // Secrets come from the environment; a missing one stops the app at startup. function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is not set`); return value; } const config: TokenConfig = { accessSecret: requireEnv("JWT_ACCESS_SECRET"), refreshSecret: requireEnv("JWT_REFRESH_SECRET"), accessTtl: 900, refreshTtl: 604800, issuer: "my-api", audience: "my-app", }; const tokens = createTokenPair(toUserId("user-123"), config, { roles: ["admin"], }); const result = verifyAccessToken(tokens.accessToken, config); if (result.valid && result.payload) { console.log(result.payload.sub, result.payload.typ, result.payload.roles); // user-123 access [ 'admin' ] } else { console.log(result.error); // e.g. "Token expired" or "Invalid signature" }

A bad token is never an exception — it comes back as { valid: false, error }. A bad config is: missing, short, or identical secrets throw AuthConfigurationError from createTokenPair and verifyAccessToken on every call, and from createAuthService() as soon as it is constructed.

The token payload carries sub (user id), iat and exp (issued-at and expiry, in Unix seconds), typ ("access" or "refresh"), jti (a random id used for revocation), optional roles, and optional sid (the session it belongs to).

refreshAccessToken() is the raw variant

The standalone refreshAccessToken(refreshToken, config) checks a signature and an expiry and nothing else: no revocation, no rotation, no user re-load, no session check. A stolen refresh token stays usable for its full seven days. For anything user-facing use createAuthService().refresh() instead.

The jwt namespace bundles the same primitives in one object, if you prefer a single import: jwt.createTokenPair, jwt.verifyAccessToken, jwt.verifyRefreshToken, jwt.refreshAccessToken, jwt.createMemoryTokenRevocationStore, jwt.parseBearerToken, jwt.isTokenExpired, jwt.extractUserId.

SESSIONS

A SessionStore is where live logins are recorded. createMemorySessionStore() keeps them in a Map — perfect for tests and single-process apps, useless across a restart or a second server.

For production, write your own object with the same five methods, backed by Redis or your database.

interface SessionStore { create(options: CreateSessionOptions): Promise<AuthSession>; get(sessionId: SessionId): Promise<AuthSession | null>; touch(sessionId: SessionId): Promise<void>; destroy(sessionId: SessionId): Promise<void>; destroyAllForUser(userId: UserId): Promise<void>; }

Create a session, extend it with activity, then throw it away.

import { createMemorySessionStore, toUserId } from "@zudojs/auth"; const store = createMemorySessionStore(); const session = await store.create({ userId: toUserId("user-123"), ttlSeconds: 3600, // idle timeout: 1 hour absoluteTtlSeconds: 86400, // hard ceiling: 24 hours ip: "127.0.0.1", }); console.log(session.id.slice(0, 8)); // first 8 chars of a 64-char hex id await store.touch(session.id); // slides the idle timeout forward console.log((await store.get(session.id)) !== null); // true await store.destroy(session.id); console.log(await store.get(session.id)); // null

There are two clocks. ttlSeconds is an idle timeout that touch() pushes forward on every request. absoluteTtlSeconds is a hard deadline measured from creation that touch() can never pass.

Always set an absolute lifetime

With only an idle timeout, a session that is used once per hour never expires — so a stolen session id is good forever. On the auth service the option is absoluteSessionTtlSeconds.

USER IDS AND SESSION IDS

UserId and SessionId are branded types. At runtime they are ordinary strings; at compile time TypeScript treats them as distinct, so you cannot pass a session id where a user id belongs, or a raw request parameter where a checked id belongs.

The catch used to be that a branded type has no public constructor. Every id you hold comes in as a plain string — from a database row, a decoded token, a cookie, a URL parameter — and the only way to get it into the branded type was a cast such as id as UserId.

toUserId() and toSessionId() are that missing constructor. They return the same string, typed correctly, and throw a TypeError if you hand them something that is not a non-empty string.

import { toUserId, toSessionId } from "@zudojs/auth"; const userId = toUserId("user-123"); // typed UserId const sessionId = toSessionId("abc123def"); // typed SessionId console.log(String(userId) === "user-123"); // true — still just a string try { toUserId(""); } catch (error) { console.log((error as TypeError).message); // toUserId: value must be a non-empty string. }
Convert at the edge

Call toUserId() / toSessionId() once, where the string enters your program (the database row, the parsed cookie), and pass the branded value inward from there. Do not sprinkle as UserId casts through your code — a cast checks nothing.

THE AUTH SERVICE

createAuthService(config) is the entry point you should use. It is the only path that gives you working logout, safe refresh-token rotation, and account-state enforcement.

Configuration

OptionRequiredWhat it does
tokenYesA TokenConfig: the two signing secrets, TTLs, issuer, audience, clock tolerance.
sessionStoreYesWhere live logins are kept.
sessionTtlSecondsYesIdle timeout for a new session, in seconds.
findUserYesLook up a user by the identifier typed at login (usually an email). Return null if unknown.
findUserByIdYesLook up a user by the UserId in a token. Used on every refresh.
verifyPasswordYesGiven a user id and a plain password, return whether it matches.
absoluteSessionTtlSecondsNoHard ceiling on session age. Strongly recommended.
permissionsNoA PermissionEngine from @zudojs/permissions, used by checkAccess().
revocationStoreNoEnables refresh-token rotation and replay detection.
allowSessionlessTokensNoAccept tokens without a sid claim (default false: they are rejected).
loginThrottleNoFailed-attempt lockout and login rate limiting.
allowInsecureFallbackGuardNoOpt in to the built-in guard when no engine is configured. Read the warning below first.
fallbackAdminRoleNoRole the fallback guard treats as superuser (default "admin").
normalizeIdentifierNoHow login() normalises the identifier before findUser. Default normalizeLoginIdentifier; false passes the raw string; or your own function.
externalSessionMethodsNoMethods createSessionForUser() may start a session for, e.g. ["oauth"]. Default none, so it throws.
findUserById is required — and it is not findUser

The two lookups take different keys. findUser receives the login identifier, usually an email; findUserById receives the UserId from the token's sub claim.

If you are upgrading and your old findUser was email-keyed, passing it for both looks fine and then fails closed: refresh() asks it for a user id, gets null back, and every token refresh throws AccountDeactivatedError. Users are silently logged out when their access token expires. Making the option required surfaces the mismatch at compile time — pass a real id-keyed lookup.

Methods

MethodWhat it does
login(credentials, context?)Checks the password, creates a session, returns { user, tokens, sessionId }.
verifyToken(token)Verifies an access token and that its session is alive. Returns the payload; throws otherwise.
refresh(refreshToken)Returns a new TokenPair, re-loading the user first.
logout(sessionId, refreshToken?)Destroys the session; also revokes the refresh token when both it and a revocation store are supplied.
logoutAll(userId)Destroys every session for the user — sign out everywhere.
createSessionForUser(userId, { method, userAgent?, ip?, metadata? })Issues a session and tokens, like login(), for a user your code already authenticated another way (OAuth, passkey, magic link). No password check. See below.
checkAccess(context)Authorization check. See RBAC below.
hashPassword(password)Convenience wrapper over the standalone hashPassword().
verifyPasswordHash(password, hash)Convenience wrapper over the standalone verifyPassword().

Refresh-token rotation

Give the service a revocationStore and each refresh token becomes single-use: the one you present is marked used, and you get a brand new pair. Presenting it a second time means someone copied it, so every session for that user is destroyed and TokenRevokedError is thrown.

This example logs in, refreshes once, then replays the old refresh token.

import { createAuthService, createMemorySessionStore, createMemoryTokenRevocationStore, toUserId, TokenRevokedError, type AuthUser, } from "@zudojs/auth"; // Secrets come from the environment; a missing one stops the app at startup. function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is not set`); return value; } const alice: AuthUser = { id: toUserId("user-123"), email: "alice@example.com", roles: ["editor"], active: true, createdAt: new Date(), }; const auth = createAuthService({ token: { accessSecret: requireEnv("JWT_ACCESS_SECRET"), refreshSecret: requireEnv("JWT_REFRESH_SECRET"), }, sessionStore: createMemorySessionStore(), revocationStore: createMemoryTokenRevocationStore(), sessionTtlSeconds: 3600, absoluteSessionTtlSeconds: 604800, findUser: async (identifier) => (identifier === alice.email ? alice : null), findUserById: async (id) => (id === alice.id ? alice : null), verifyPassword: async () => true, }); const { tokens } = await auth.login({ identifier: "alice@example.com", password: "anything", }); const rotated = await auth.refresh(tokens.refreshToken); console.log(rotated.refreshToken !== tokens.refreshToken); // true try { await auth.refresh(tokens.refreshToken); // replay of a used token } catch (error) { console.log(error instanceof TokenRevokedError); // true }

verifyPassword: async () => true keeps the example short. Never write that in a real application.

Brute-force lockout

Add loginThrottle and repeated failures start costing the attacker. After maxFailedAttempts (default 5) the identifier is locked for lockoutSeconds (default 900) and login() throws AccountLockedError. Beyond maxAttemptsPerWindow (default 20) it throws AuthRateLimitError instead. A failure is reserved before the password is checked, so even a parallel burst gets only maxFailedAttempts guesses. Both budgets are per identifier: put a per-IP limiter (createRateLimiter from @zudojs/security) in front of login(). The in-memory store forgets an unlocked failure streak after failureTtlSeconds (default 900) and caps tracked identifiers at maxEntries (default 100 000).

import { AccountLockedError, createAuthService, createMemoryLoginAttemptStore, createMemorySessionStore, } from "@zudojs/auth"; function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is not set`); return value; } const auth = createAuthService({ token: { accessSecret: requireEnv("JWT_ACCESS_SECRET"), refreshSecret: requireEnv("JWT_REFRESH_SECRET"), }, sessionStore: createMemorySessionStore(), sessionTtlSeconds: 3600, findUser: async () => null, // every login fails in this example findUserById: async () => null, verifyPassword: async () => false, loginThrottle: { store: createMemoryLoginAttemptStore({ windowSeconds: 60 }), maxFailedAttempts: 5, // -> AccountLockedError (HTTP 423) lockoutSeconds: 900, maxAttemptsPerWindow: 20, // -> AuthRateLimitError (HTTP 429) windowSeconds: 60, }, }); for (let attempt = 1; attempt <= 6; attempt++) { try { await auth.login({ identifier: "mallory@example.com", password: "guess" }); } catch (error) { if (error instanceof AccountLockedError) { console.log(attempt, error.statusCode, error.code, error.retryAfterSeconds, error.headers); // 6 423 ERR_ACCOUNT_LOCKED 900 { 'retry-after': '900' } } } }

AccountLockedError and AuthRateLimitError carry retryAfterSeconds (for the lockout, the time left on the lock) and a matching Retry-After header in error.headers. Thrown from a @zudojs/http handler, the error answers 423 with that header copied onto the response.

Counters are keyed by the submitted identifier — trimmed, NFKC-normalised and lower-cased, so case or whitespace variants share one budget — not by a resolved user, so unknown and real accounts are throttled identically. Both memory stores are per-process; back them with Redis if you run more than one instance.

SESSIONS FOR OAUTH AND OTHER SIGN-INS

A user authenticated some other way — the callback of @zudojs/auth-oauth, a passkey, a magic link — still needs a session and tokens. createSessionForUser() issues them exactly as login() would, without a password check.

import { createAuthService, createMemorySessionStore, toUserId, type AuthUser } from "@zudojs/auth"; function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is not set`); return value; } const alice: AuthUser = { id: toUserId("user-123"), email: "alice@example.com", roles: ["editor"], active: true, createdAt: new Date(), }; const auth = createAuthService({ token: { accessSecret: requireEnv("JWT_ACCESS_SECRET"), refreshSecret: requireEnv("JWT_REFRESH_SECRET"), }, sessionStore: createMemorySessionStore(), sessionTtlSeconds: 3600, findUser: async () => null, // no password logins in this example findUserById: async (id) => (id === alice.id ? alice : null), verifyPassword: async () => false, externalSessionMethods: ["oauth"], // off by default }); // In the OAuth callback, after state and PKCE were verified and the provider // identity was mapped to one of YOUR users. Never use a user id from the request. const { user, tokens, sessionId } = await auth.createSessionForUser(alice.id, { method: "oauth", metadata: { provider: "github" }, // stored on the session with authMethod: "oauth" }); console.log(user.email, (await auth.verifyToken(tokens.accessToken)).sub); // alice@example.com user-123 try { await auth.createSessionForUser(alice.id, { method: "magic-link" }); } catch (error) { console.log((error as Error).name); // AuthConfigurationError — not in externalSessionMethods } await auth.logout(sessionId); // ends it like any other session
It checks no credential

Your code is asserting that the user is authenticated, so the method is off unless you list it in externalSessionMethods; any other method throws AuthConfigurationError. It still loads the user with findUserById() and refuses an unknown user (InvalidCredentialsError) or a deactivated one (AccountDeactivatedError). Never pass it a user id taken from the request: a route that forwards a client-supplied id hands out sessions for any account. The session records metadata.authMethod, and logout() / logoutAll() end it like any other. Added in 1.3.0.

RBAC & PERMISSIONS

checkAccess(context) answers "may this user do this?". You pass a GuardContext — the user id, their roles, the permission being demanded, and optionally who owns the resource — and get back a GuardResult with allowed, a reason from the engine, and the roles that were checked.

The matching itself is done by a permission engine you supply.

import { createAuthService, createMemorySessionStore, toUserId, type AuthUser, } from "@zudojs/auth"; import { createPermissionEngine } from "@zudojs/permissions"; // Secrets come from the environment; a missing one stops the app at startup. function requireEnv(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is not set`); return value; } const alice: AuthUser = { id: toUserId("user-123"), email: "alice@example.com", roles: ["viewer"], active: true, createdAt: new Date(), }; const auth = createAuthService({ token: { accessSecret: requireEnv("JWT_ACCESS_SECRET"), refreshSecret: requireEnv("JWT_REFRESH_SECRET"), }, sessionStore: createMemorySessionStore(), sessionTtlSeconds: 3600, findUser: async (identifier) => (identifier === alice.email ? alice : null), findUserById: async (id) => (id === alice.id ? alice : null), verifyPassword: async () => true, permissions: createPermissionEngine({ roles: [ { name: "admin", permissions: ["users:read", "users:write"] }, { name: "viewer", permissions: ["users:read"] }, ], }), }); const readCheck = await auth.checkAccess({ userId: "user-123", roles: ["viewer"], permission: "users:read", }); console.log(readCheck.allowed); // true const writeCheck = await auth.checkAccess({ userId: "user-123", roles: ["viewer"], permission: "users:write", }); console.log(writeCheck.allowed, writeCheck.reason); // false no_matching_rule

Note that checkAccess() takes the roles you give it, not the roles on the stored user. In an HTTP handler, read them from the verified token payload.

The fallback guard grants too much

Without a permissions engine, checkAccess() throws AuthConfigurationError rather than guess. Setting allowInsecureFallbackGuard: true switches on a built-in guard that grants a resource owner every permission and the admin role everything, ignoring the permission string entirely. It is for prototypes only.

REQUEST UTILITIES

Small helpers for the HTTP edge. All of them accept unknown and never throw, because header values arriving from a client cannot be trusted to be strings at all.

import { parseBearerToken, parseCookies, isTokenExpired, extractUserId, generateCsrfToken, } from "@zudojs/auth"; console.log(parseBearerToken("Bearer abc.def.ghi")); // "abc.def.ghi" console.log(parseBearerToken("Basic abc")); // null console.log(parseCookies("session=abc; theme=dark").session); // "abc" console.log(isTokenExpired("not-a-token")); // true console.log(extractUserId("not-a-token")); // null console.log(generateCsrfToken().length); // 64

Putting it together, a route handler reads the header, verifies the token, and gets a payload it can trust.

// `auth` is the service built in Quick Start; `authorization` is the // raw Authorization header of the incoming request. async function currentUserId(authorization: unknown): Promise<string | null> { const token = parseBearerToken(authorization); if (!token) return null; try { const payload = await auth.verifyToken(token); return payload.sub; } catch { return null; // expired, tampered with, or the session was ended } }
isTokenExpired and extractUserId do not verify

Both read the token's payload without checking the signature, so anybody can put anything in there. Use them as hints ("should I refresh before calling?"), never to decide who the caller is. Only verifyToken(), verifyAccessToken() and verifyRefreshToken() check the signature.

API REFERENCE

Functions

NameWhat it doesNotes
createAuthService(config)Builds the full auth service.The entry point for real applications.
hashPassword(password, saltLength?)Hashes a password with scrypt (N=16384, r=8, p=5) via @zudojs/crypto.Async. Salt length 16–64 bytes, default 32.
verifyPassword(password, hash)Checks a password against a stored hash.Async. Returns false instead of throwing.
needsRehash(hash)Says whether a stored hash uses outdated settings.Call after a successful login. false for current hashes, including @zudojs/crypto's defaults.
normalizeLoginIdentifier(identifier)NFKC, trim, and lower-case for an email address.What login() applies by default. Use it at registration too.
generateRandomToken(length?)Random hex string for reset links and similar.Length in bytes, 16–1024, default 32.
createTokenPair(userId, config, options?)Mints an access + refresh pair.Synchronous. No session binding unless you pass sessionId; createAuthService() rejects such unbound tokens unless allowSessionlessTokens is true.
verifyAccessToken(token, config)Verifies an access token.Returns a result object; only bad config throws.
verifyRefreshToken(token, config)Verifies a refresh token.Same shape, refresh secret.
refreshAccessToken(token, config, options?)Raw exchange of a refresh token for a new pair.No rotation or revocation. Prefer service.refresh(). A refresh token that carries a sid produces a pair bound to the same sid, so it still dies with logout() when verified through the service.
createMemorySessionStore(options?)In-process SessionStore.Development and tests only.
createMemoryTokenRevocationStore(options?)In-process TokenRevocationStore.Enables atomic rotation in one process.
createMemoryLoginAttemptStore(options?)In-process LoginAttemptStore.Counters are not shared between instances.
toUserId(value)Brands a string as a UserId.Throws TypeError on an empty value.
toSessionId(value)Brands a string as a SessionId.Throws TypeError on an empty value.
parseBearerToken(authorization)Pulls the token out of an Authorization header.null when it is not a Bearer header.
parseCookies(cookie)Parses a Cookie header into an object.Null-prototype object; at most 100 pairs.
isTokenExpired(token)Reads exp without verifying.Hint only. Unparseable input reads as expired.
extractUserId(token)Reads sub without verifying.Hint only. Never trust it.
generateCsrfToken()32 random bytes as hex.64 characters.
jwtNamespace object bundling the JWT primitives.Does not include createAuthService.

Types

NameWhat it isNotes
AuthUserThe user record you return from your lookups.id, email, roles, active, createdAt required.
UserCredentials{ identifier, password } passed to login().Same shape as PasswordCredentials.
UserRegistrationSign-up input shape: email, password, name, roles.A contract for your own code; nothing consumes it here.
AuthServiceConfigEverything createAuthService() accepts.See the configuration table above.
AuthServiceThe object createAuthService() returns.
LoginResult{ user, tokens, sessionId }.Returned by login().
UserLookup / UserByIdLookupSignatures of findUser / findUserById.Different keys — see the warning above.
PasswordVerifierSignature of the verifyPassword option.(userId, password) => Promise<boolean>.
TokenConfigSecrets, TTLs, issuer, audience, clock tolerance.Secrets ≥ 32 bytes and must differ.
TokenPair{ accessToken, refreshToken, expiresIn, tokenType }.tokenType is always "Bearer".
TokenPayloadWhat is inside a token.sub, iat, exp, typ, jti, roles?, sid?.
TokenVerificationResult{ valid, payload?, error? }.Returned by the standalone verifiers.
TokenRevocationStoreInterface for storing revoked jti values.Implement revokeIfNotRevoked in production.
JwtToken / TokenIdA token string; a token id.TokenId is branded.
SessionStoreInterface for session storage.Five methods; implement for Redis.
AuthSession / CreateSessionOptionsA stored session; the options to create one.Idle plus absolute expiry.
UserId / SessionIdBranded id strings.Build them with toUserId / toSessionId.
LoginAttemptStore / LoginAttemptRecord / LoginThrottleConfigBrute-force protection pieces.Back the store with Redis for multi-instance apps.
Permission / RoleRe-exports from @zudojs/permissions.Prefer importing them from that package in new code.
GuardContext / GuardResultInput and output of checkAccess().
PasswordCredentials / ApiKeyCredentialsCredential shapes.API-key verification is not implemented here.
ExternalSessionOptions / ExternalSessionResultInput and output of createSessionForUser().{ method, userAgent?, ip?, metadata? }; the result has the login() shape.

Errors

Every error extends AuthError, re-exported from @zudojs/errors (a BaseError). Each carries an HTTP status code and a message that is safe to show a client — the messages never reveal whether an account exists.

NameThrown whenStatus / code
AuthErrorBase class for everything below.401 ERR_AUTHENTICATION
AuthConfigurationErrorBad secrets, checkAccess() with no engine, or createSessionForUser() with an unlisted method.500 ERR_CONFIGURATION_INVALID
InvalidCredentialsErrorUnknown user or wrong password.401 ERR_INVALID_CREDENTIALS
TokenExpiredErrorThe token's exp has passed.401 ERR_TOKEN_EXPIRED
TokenInvalidErrorMalformed, mis-signed, or wrong-type token.401 ERR_TOKEN_INVALID
TokenRevokedErrorA used refresh token was replayed.401 ERR_TOKEN_REVOKED
AccountLockedErrorToo many failed logins.423 ERR_ACCOUNT_LOCKED
AccountDeactivatedErrorThe account is not active.403 ERR_ACCOUNT_DEACTIVATED
AccessDeniedErrorInsufficient permissions.403 ERR_ACCESS_DENIED
SessionExpiredErrorThe token's session is gone or expired.401 ERR_SESSION_EXPIRED
AuthRateLimitErrorToo many login attempts in the window.429 ERR_RATE_LIMITED

Changed in 1.3.0: AccountLockedError, AccountDeactivatedError and TokenRevokedError used to share the code ERR_FORBIDDEN, and TokenRevokedError was a 403. Each now has its own code, and a revoked token is a 401 (category authentication), because the client has to sign in again. A client that matched ERR_FORBIDDEN for these, or treated a revoked refresh token as a 403, must be updated.

AuthErrorOptions is exported too, for constructing these yourself. AccountLockedError and AuthRateLimitError carry retryAfterSeconds (also in their metadata) and a Retry-After header in headers, which @zudojs/http copies onto the response.

Constants

NameValueNotes
MIN_SALT_LENGTH16Smallest salt hashPassword() accepts, in bytes.
MAX_SALT_LENGTH64Largest accepted salt, in bytes.
MAX_PASSWORD_BYTES1024Longer passwords are rejected rather than hashed.

COMMON MISTAKES

  • Passing your email-keyed findUser as findUserById. Every refresh looks up a user id in an email-keyed table, gets nothing, and throws AccountDeactivatedError. Users drop out roughly 15 minutes after logging in. Fix: give findUserById a real id-keyed lookup.
  • Using the same string for accessSecret and refreshSecret. createAuthService() throws AuthConfigurationError at construction, because one secret would let a refresh token be presented as an access token. Fix: two different secrets, each at least 32 bytes.
  • Calling the standalone refreshAccessToken() on a public route. It does not rotate, revoke, re-load the user, or check the session, so a stolen refresh token works for a week. Fix: use createAuthService().refresh() with a revocationStore.
  • Trusting extractUserId() to identify the caller. It reads an unverified payload, so anyone can forge a user id. Fix: call verifyToken() and use payload.sub.
  • Shipping the in-memory stores. Sessions, revocations and attempt counters vanish on restart and are not shared between instances, so logouts and lockouts do not hold. Fix: implement SessionStore, TokenRevocationStore and LoginAttemptStore against Redis or your database.
  • Setting allowInsecureFallbackGuard: true to make checkAccess() stop throwing. The fallback ignores the permission string and grants owners and admins everything. Fix: configure a permissions engine.
  • Passing a request value to createSessionForUser(). It checks no password, so a route that forwards a client-supplied user id signs anyone in as anyone. Fix: call it only after your own verification (OAuth state and PKCE, a passkey assertion) mapped the identity to a user, and list only the methods you use in externalSessionMethods.
  • Forgetting absoluteSessionTtlSeconds. An idle timeout alone lets an active session live forever, so a stolen session id never dies. Fix: set a hard ceiling, for example seven days.
PackageWhen to reach for it
@zudojs/auth-oauthSign in with Google, GitHub and other third-party providers. All OAuth2 support now lives there.
@zudojs/permissionsThe engine behind checkAccess() — roles, permission matching, and ownership rules.
@zudojs/errorsThe base error class every auth error extends, and the shared error codes.
@zudojs/constantsWhere the branded UserId, SessionId and TokenId types are defined.
@zudojs/httpWiring auth into request handling — read the header, verify, attach the user.
@zudojs/securityCSRF protection, rate limiting and hardening that sit alongside auth.

NEXT STEPS

COMPLETE EXPORT INDEX

Every name @zudojs/auth exports from its package root at v1.2.1 — 65 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.

Show all 65 exports
Classes (11)
AccessDeniedError AccountDeactivatedError AccountLockedError AuthConfigurationError AuthError AuthRateLimitError InvalidCredentialsError SessionExpiredError TokenExpiredError TokenInvalidError TokenRevokedError
Functions (19)
createAuthService createMemoryLoginAttemptStore createMemorySessionStore createMemoryTokenRevocationStore createTokenPair extractUserId generateCsrfToken generateRandomToken hashPassword isTokenExpired needsRehash parseBearerToken parseCookies refreshAccessToken toSessionId toUserId verifyAccessToken verifyPassword verifyRefreshToken
Interfaces (22)
ApiKeyCredentials AuthErrorOptions AuthService AuthServiceConfig AuthSession AuthUser CreateSessionOptions GuardContext GuardResult LoginAttemptRecord LoginAttemptStore LoginResult LoginThrottleConfig PasswordCredentials SessionStore TokenConfig TokenPair TokenPayload TokenRevocationStore TokenVerificationResult UserCredentials UserRegistration
Type aliases (9)
JwtToken PasswordVerifier Permission Role SessionId TokenId UserByIdLookup UserId UserLookup
Constants (4)
jwt MAX_PASSWORD_BYTES MAX_SALT_LENGTH MIN_SALT_LENGTH