npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@authlock/core

v0.4.0

Published

Framework-agnostic, django-axes-style login lockout engine — persistent failed-attempt tracking, failure limits, tiered cooloff, and a pluggable store (in-memory + Drizzle)

Downloads

162

Readme

@authlock/core

[!NOTE] Published on npm and following semver. Version history is in the changelog.

What it is

@authlock/core tracks failed login attempts in a store you already run and locks an identity out once it trips a failure limit — the classic django-axes behaviour, ported to TypeScript and framework-agnostic:

  • Configurable identity dimensions — key lockout on username, IP, a combination, or any custom dimension. A lock trips if any configured parameter key reaches the limit within the cooloff window.
  • Failure limit → lockout, with cooloff — including tiered cooloff (escalate the wait as failures pile up), reset-on-success, a whitelist predicate, and a Retry-After hint.
  • Pluggable LockoutStore — an in-memory store (single-instance) ships in the box; Drizzle-backed Postgres / SQLite / MySQL stores (with an atomic cross-instance increment) ship from subpaths.
  • Zero runtime dependencies — no framework, no DI, no decorators. Use it from Express, inversify, tsyringe, a bare script, or via the thin @nest-native/lockout NestJS adapter.

Quick start

import { LockoutManager, InMemoryLockoutStore } from '@authlock/core';

const lockout = new LockoutManager({
  store: new InMemoryLockoutStore(),
  limit: 5, // lock after 5 failures…
  cooloffMs: 15 * 60_000, // …for 15 minutes
  parameters: [['username'], ['ip']], // lock by username OR by IP
});

// In your login handler — identity extraction is YOUR trust decision:
const identity = { username, ip: req.ip };

// 1. Gate before checking the credential.
const gate = await lockout.check(identity);
if (gate.locked) {
  res.setHeader('Retry-After', Math.ceil(gate.retryAfterMs! / 1000));
  return res.status(429).json({ error: 'too many attempts' });
}

// 2. Verify the credential however you like, then report the outcome.
if (await verifyPassword(username, password)) {
  await lockout.recordSuccess(identity); // clears the failure counters
  // …issue a session
} else {
  const decision = await lockout.recordFailure(identity);
  return res.status(decision.locked ? 429 : 401).json({ error: 'invalid' });
}

check never mutates state, so it is safe to call on every request; recordFailure and recordSuccess are the two writes, called with the authentication outcome. There is no ambient magic — you wire these three calls into your own handler.

Unlocking an identity

recordSuccess clears the counters on a successful login. For an administrative unlock — a support tool, an "unlock user" button, an unlock-via-email link — use reset, which clears the counters unconditionally (it ignores resetOnSuccess and the whitelist):

await lockout.reset({ username, ip }); // administratively unlock

Locking by username is a denial-of-service vector

This is inherent to identity-based lockout (django-axes has the same property): if you lock on ['username'], anyone can lock a victim out of their own account just by submitting failed logins for that username. Mitigate it:

  • Prefer combination parameters (['username', 'ip']) over ['username'] alone, so an attacker must also control the victim's IP.
  • Keep a separate, looser ['ip'] parameter to catch distributed guessing.
  • Consider a softer response for the username dimension (a CAPTCHA or a delay) and reserve a hard lock for the IP dimension.

The library locks exactly what you configure — choosing safe parameters is your decision. The cooloff cools off from the last failure, so a persistent attacker stays locked with no gaps; the window (from the first failure) caps how long any single run can keep an identity locked, at windowMs.

Security & operations

  • Do not trust X-Forwarded-For. This is the vulnerability class that hit django-axes and other tools: if the IP you key on comes from a spoofable header, an attacker can rotate it to bypass IP lockout or forge a victim's IP to lock them out. The engine keys on whatever ip you put in Identifiers — pass the real connection address, and only trust a proxy header if your proxy sets it and strips any client-supplied value. (The NestJS adapter's default extractor uses req.ip, not X-Forwarded-For.) Because a lock trips if any parameter trips, a ['username'] parameter still catches a single-target brute force even when the IP is spoofable.
  • Don't whitelist on a spoofable dimension. A whitelist keyed on an IP an attacker can forge is a bypass; key it on something they can't control.
  • Normalize identity dimensions. Alice, alice, and alice hash to three different counters, so on a case-insensitive login an attacker bypasses the limit by varying case or whitespace. Configure a per-dimension normalize map (applied before hashing, on every path — check / record / reset) — or normalize in your own extractor:
    new LockoutManager({
      parameters: [['username'], ['ip']],
      normalize: { username: (v) => v.trim().toLowerCase() }, // ip left verbatim
      // ...
    });
  • Bound store growth. Every distinct identity creates a record, so a flood of fabricated usernames/IPs grows the store. Schedule pruneExpired() (it drops records past their window, capping the store to identities active within windowMs), put a request rate limiter in front (e.g. @nestjs/throttler, which caps distinct keys per second), and use a Drizzle store — not the in-memory one — for hostile or multi-instance deployments.
  • fail-open trades protection for availability during a store outage. With the default failMode: 'open', a store error allows the attempt (and logs) — so while the store is down, brute-force protection is off. Use failMode: 'closed' if you would rather deny during an outage.

A shared, durable store

The in-memory store is single-process. For multiple instances (or to survive a restart) use a Drizzle store — the increment is a single atomic statement, so concurrent failed attempts across nodes count exactly once each:

import { drizzle } from 'drizzle-orm/node-postgres';
import { LockoutManager } from '@authlock/core';
import { PostgresLockoutStore, pgLockoutTable } from '@authlock/core/postgres';

// Add this table to your Drizzle schema + migration:
export const lockoutAttempts = pgLockoutTable(); // 'lockout_attempts' by default

const lockout = new LockoutManager({
  store: new PostgresLockoutStore(drizzle(pool), lockoutAttempts),
  limit: 5,
  cooloffMs: 15 * 60_000,
  parameters: [['username'], ['ip']],
});

drizzle-orm is an optional peer — you only need it (and a driver) if you use a Drizzle store. Bring your own driver: the store never opens a connection.

Entry points

| Import | Contents | | --- | --- | | @authlock/core | the engine — LockoutManager, InMemoryLockoutStore, types, deriveKeys, policy helpers | | @authlock/core/drizzle | all three Drizzle stores + table factories (needs drizzle-orm, no driver) | | @authlock/core/postgres | PostgresLockoutStore + pgLockoutTable | | @authlock/core/sqlite | SqliteLockoutStore + sqliteLockoutTable | | @authlock/core/mysql | MysqlLockoutStore + mysqlLockoutTable |

Policy options

| Option | Meaning | | --- | --- | | limit | failures allowed before a key locks (locks at exactly limit) | | cooloffMs | base lock duration once locked | | windowMs? | failure-counting window; defaults to the effective cooloff | | tiers? | escalating cooloff by failure count, e.g. [{ atFailures: 10, cooloffMs: 3_600_000 }] | | parameters | dimension combinations to evaluate; a lock trips if any trips | | whitelist? | (id) => boolean — identities that are never counted or locked | | resetOnSuccess? | clear failures on success (default true) | | failMode? | 'open' (default: allow + log on store error) or 'closed' (deny) |

Design principles

  • The core is the cross-framework story. It stays zero-dependency and framework-neutral on purpose — no bespoke inversify/tsyringe adapter packages.
  • Correctness is the product. This is security-critical code: the store's increment is atomic across instances, every configured parameter key is checked, and every change ships with a security pass.
  • fail-open by default. If the store errors, the engine allows the attempt and logs — a database blip must not lock every user out. failMode: 'closed' is available for high-security deployments. Both paths are tested.
  • Identity extraction is the application's trust decision. The engine never reads X-Forwarded-For or any proxy header for you.
  • Not a rate limiter. For request-rate throttling use @nestjs/throttler; this library is about failed-authentication lockout.

MIT licensed. Part of the nest-native family. Not affiliated with the NestJS core team or the django-axes project.