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

linkotp

v1.0.0

Published

One email, two ways in. A dependency-free, framework-agnostic passwordless auth primitive that sends a typed code and a scanner-safe magic link in a single message.

Readme

linkotp

CI npm version License: MIT

One email, two ways in. A passwordless auth primitive that issues a typed code and a scanner-safe magic link as a single challenge, delivered in one message.

  • Zero runtime dependencies. Web Standard APIs only. (linkotp/better-auth declares Better Auth as an optional peer, so it installs nothing unless you import it.)
  • Runs anywhere. Node, Bun, Deno, Cloudflare Workers, Vercel Edge.
  • No framework opinion. Next.js, SvelteKit, Remix, Astro, Hono, Express, or your own routes.
  • No database opinion. Postgres, SQLite, D1, Turso, MySQL, or six methods of your own.
  • Does not mint sessions. It proves an address; your session library does the rest.
npm install @yasirdora/linkotp

Why this exists

Sending a code and a link in one email is a well-worn pattern — Slack, Notion, Linear, and Vercel all do it. Implementing it on top of an existing OTP plugin is where it goes wrong, in two specific ways.

1. The link must not carry the code

The tempting shortcut is to put the OTP in the URL, either plainly or "hidden" in base64:

https://example.com/auth/verify?code=418207&[email protected]   ❌
https://example.com/auth/verify?payload=eyJjb2RlIjoiNDE4MjA3In0=    ❌

(Note: Base64 is an encoding, not encryption. Anyone who intercepts the second link can trivially decode it to find the code).

Now the link is only as strong as the code. Six digits is about 20 bits — perfectly safe for a value typed into a rate-limited form, and far too weak for a bearer credential that lands in browser history, server access logs, CDN logs, and Referer headers. Those are two different threat models, and one secret cannot serve both.

linkotp issues two independent secrets bound to one challenge:

| | Entropy | Where it travels | |---|---:|---| | Code — 6 chars over a 32-char alphabet | ~30 bits | Typed into a form | | Token — 48 chars over a 62-char alphabet | ~286 bits | Carried in a URL |

Redeeming either one retires the other. Both are stored as HMAC digests keyed by a secret that lives outside the database.

2. A magic link must not be consumed by GET

Microsoft Defender Safe Links, Proofpoint, Mimecast, and Barracuda fetch every URL in inbound mail before the recipient sees it. Consumer clients add link previews; browsers prefetch.

If GET consumes the token, each of those fetches burns a single-use credential. The user clicks a link that was valid seconds ago and is told it expired. Worse, the scanner's request succeeds — the server mints a real session and hands it to a security appliance, which throws it away.

This is also plain HTTP: RFC 9110 requires GET to be safe, and spending a one-time credential is not.

linkotp's GET renders a confirmation page and touches nothing. Only the POST that page submits consumes the token. Automated fetchers issue GET and stop.


Quick start

import { createLinkOtp } from "@yasirdora/linkotp";
import { createSqlStore, schemaFor } from \"@yasirdora/linkotp/stores\";

const auth = createLinkOtp({
  secret: process.env.LINKOTP_SECRET!,   // 32+ chars, from the environment
  baseUrl: "https://example.com",
  store: createSqlStore({ driver, dialect: "postgres" }),
  mailer: async (message) => {
    await resend.emails.send({
      from: "Example <[email protected]>",
      to: message.to,
      subject: message.subject,
      html: message.html,
      text: message.text,
      headers: message.headers,
    });
  },
});

Create the table once with schemaFor("postgres"), then:

await auth.start({ email: "[email protected]" });

// The user types the code…
const identity = await auth.verifyCode({ email, code });

// …or clicks the link.
const identity = await auth.verifyToken({ token });

identity; // { email, purpose, metadata, via: "code" | "link", verifiedAt, challengeId }

Generate a secret:

node -e "console.log(crypto.randomBytes(32).toString('base64url'))"

The HTTP layer

createHandler returns one (Request) => Promise<Response> built on the Fetch API, with the GET/POST split, the CSP nonce, and the security headers already correct.

import { createHandler } from \"@yasirdora/linkotp/http\";

export const handler = createHandler(auth, {
  async onVerified(identity, request) {
    // linkotp does not create sessions. This is where you do.
    const cookie = await mySessionLibrary.create(identity.email);
    return { headers: { "Set-Cookie": cookie }, redirectTo: "/dashboard" };
  },
});

It serves four routes:

| Route | Method | What it does | |---|---|---| | /api/auth/start | POST | Issues and sends a challenge | | /api/auth/verify | POST | Redeems a typed code | | /auth/verify | GET | Renders the confirmation page — consumes nothing | | /auth/verify | POST | Redeems the link token |

Both paths are configurable (basePath, and verifyPath on the instance).

Framework recipes

// app/api/auth/[...route]/route.ts
export const POST = handler;

// app/auth/verify/route.ts
export const GET = handler;
export const POST = handler;
// src/routes/[...route]/+server.ts
export const GET = ({ request }) => handler(request);
export const POST = ({ request }) => handler(request);
app.all("/api/auth/*", (c) => handler(c.req.raw));
app.all("/auth/verify", (c) => handler(c.req.raw));
export default { fetch: handler };            // Workers
Deno.serve(handler);                          // Deno
Bun.serve({ fetch: handler });                // Bun
// src/pages/api/auth/[...route].ts
export const ALL = ({ request }) => handler(request);

Express predates the Fetch API, so convert at the boundary:

import { Readable } from "node:stream";

app.use(async (req, res, next) => {
  const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
  const request = new Request(url, {
    method: req.method,
    headers: req.headers as Record<string, string>,
    body: ["GET", "HEAD"].includes(req.method) ? undefined : req,
    duplex: "half",
  });

  const response = await handler(request);
  if (response.status === 404) return next();

  res.status(response.status);
  for (const [k, v] of response.headers) res.append(k, v);
  if (response.body) Readable.fromWeb(response.body).pipe(res);
  else res.end();
});

Or skip the handler entirely and call start / verifyCode / verifyToken from your own routes. The core has no HTTP dependency.


Stores

import { createMemoryStore, createSqlStore, schemaFor } from \"@yasirdora/linkotp/stores\";

createSqlStore needs a two-method driver, so any client works:

// Cloudflare D1
const driver = {
  all: (sql, params) => env.DB.prepare(sql).bind(...params).all().then((r) => r.results),
  run: (sql, params) =>
    env.DB.prepare(sql).bind(...params).run().then((r) => ({ rowsAffected: r.meta.changes })),
};

// node:postgres, postgres.js, better-sqlite3, libSQL — same shape.

Dialects: sqlite (default), postgres, mysql. SQLite and Postgres use RETURNING; MySQL takes an equivalent two-statement path that is still atomic.

Writing your own

The interface is six methods, and one of them carries the entire security model. consume must be a single guarded compare-and-set:

UPDATE linkotp_challenge
   SET consumed_at = :now
 WHERE token_hash  = :tokenHash
   AND consumed_at IS NULL
   AND expires_at  > :now
   AND attempts    < max_attempts
RETURNING *

A read-then-write implementation has a window in which two callers both see the row as unconsumed, and a single-use token authenticates twice. The compiler cannot catch that, so there is a suite that can:

import { checkStoreConformance } from \"@yasirdora/linkotp/testing\";

const report = await checkStoreConformance({ createStore: () => myStore() });
assert.ok(report.passed, report.summary);

Seventeen checks, including firing 24 concurrent consume calls at one challenge and asserting exactly one wins. Run it in your own CI.


Better Auth

npm install @yasirdora/linkotp better-auth
import { betterAuth } from "better-auth";
import { linkotp } from \"@yasirdora/linkotp/better-auth\";

export const auth = betterAuth({
  database: db,
  plugins: [
    linkotp({
      secret: process.env.LINKOTP_SECRET!,   // 32+ chars, from the environment
      baseUrl: "https://example.com",
      mailer: async (message) => { await send(message); },
    }),
  ],
});

Then npx @better-auth/cli generate to create the challenge table, and migrate to apply it.

On the client:

import { createAuthClient } from "better-auth/client";
import { linkotpClient } from \"@yasirdora/linkotp/better-auth/client\";

export const authClient = createAuthClient({ plugins: [linkotpClient()] });

await authClient.signIn.linkotp({ email });            // sends the email
await authClient.signIn.linkotp.code({ email, code }); // redeems the typed code

The link arm needs no client call — the user clicks it and lands back on your app with a session. Method names, argument types, and return types are all inferred from the server plugin, so there is nothing to keep in sync.

| Endpoint | Method | What it does | |---|---|---| | /sign-in/linkotp | POST | Issues one challenge and mails the code and the link | | /sign-in/linkotp/code | POST | Redeems the typed code | | /linkotp/verify | GET | Renders the confirmation page. Consumes nothing. | | /linkotp/verify | POST | Redeems the link token |

The GET/POST split is the point. Better Auth's built-in magicLink redeems on GET, which is why discussion #6985 is open: Defender Safe Links, Proofpoint, Mimecast, and Barracuda fetch every URL in inbound mail, so the scanner spends the credential and the user is told their brand-new link expired. The usual workaround — raising allowedAttempts — turns a single-use credential into a multi-use one, which is a downgrade dressed as a fix. Here the scanner gets HTML and the token survives; and because the same email carries a code on a separate secret, a user whose link is mangled entirely still has a way in.

Sessions stay Better Auth's. linkotp verifies control of an address and hands off to internalAdapter and setSessionCookie.

A link that has expired, been redeemed, or been retired by too many wrong guesses is the ordinary end of a challenge's life, and the person clicking it is in a browser. Those all redirect to errorCallbackURL with ?error=<code> rather than rendering a JSON error body:

linkotp({
  // ...
  defaultCallbackURL: "/dashboard",
  errorCallbackURL: "/sign-in",   // receives ?error=invalid_token
});

Expired rows are inert — the consume guard enforces expiry regardless — but they do accumulate. Better Auth has no scheduler, so call sweep() from your own cron if table size matters:

import { createBetterAuthStore } from \"@yasirdora/linkotp/better-auth\";

const { adapter } = await auth.$context;      // note: $context is a promise
await createBetterAuthStore({ adapter }).deleteExpired(Date.now());

On the store. The plugin persists challenges through Better Auth's own adapter, so there is no second database connection to configure. Its Where clause compares a field to a literal and never to another field, so attempts < maxAttempts cannot be expressed; the table stores attemptsRemaining and guards attemptsRemaining > 0 instead. Same meaning, and the guard stays inside a single updateMany, which is what keeps consume an atomic compare-and-set.

The bundled conformance suite runs against it twice: once on the memory adapter, and once on real SQLite through Better Auth's Kysely adapter, with the table created by Better Auth's own migrator. The second run is what establishes that the guard actually compiles to one UPDATE and that a genuine row lock elects the winner — on the memory adapter that would be JavaScript's single thread doing the work.

Note. Better Auth types updateMany as Promise<number>. Its first-party adapters honour that as of 1.7; through 1.6.2 the Drizzle adapter returned the raw driver result and the memory adapter returned the updated record. The store reads every documented driver shape and throws on one it cannot read, rather than reporting zero rows — an adapter incompatibility should not look like an expired link.

Requires better-auth@>=1.7.0, and this entry point is ESM-only, because Better Auth is.

Not yet supported here: device binding (binding.enabled), which needs a cookie this entry point does not yet set — the plugin refuses to start rather than ignore it, so nobody deploys believing they have it. linkotp/http implements it. The plugin also covers sign-in only; email verification and password reset are purposes the core supports but the plugin does not expose.


Security model

| Property | How | |---|---| | Secrets at rest | HMAC-SHA256 keyed by an application secret, domain-separated per use. A database leak alone reveals nothing. | | Code binding | The code digest includes the address, so a captured digest cannot be replayed against another account. | | Single use | Enforced by a guarded atomic UPDATE, not by application logic. | | Brute force | Configurable attempt ceiling; the guard re-checks it on every claim. | | Randomness | crypto.getRandomValues with rejection sampling. Naive byte % 62 skews the first eight characters by ~25%. | | Enumeration | shouldSend suppresses delivery while returning an identical result, padded to a latency floor. | | Open redirect | Every redirect is restricted to a same-origin path. | | Login CSRF | State-changing POSTs are checked against Sec-Fetch-Site / Origin. | | Token leakage | Referrer-Policy: no-referrer, Cache-Control: no-store, X-Robots-Tag: noindex, and the token is stripped from the address bar on load. | | XSS on the confirmation page | default-src 'none' with a fresh per-response nonce. | | Config errors | Weak secrets, non-https origins, and under-entropy tokens are rejected at startup, not at 3am. |

Two things it deliberately does not do: create sessions, and rate-limit across instances. Both are yours, with hooks provided (onVerified, RateLimiter).

See SECURITY.md for the full threat model and the residual risks.


Configuration

createLinkOtp({
  secret,                       // required, 32+ chars
  baseUrl,                      // required, https outside localhost
  store, mailer,                // required

  verifyPath: "/auth/verify",
  ttlSeconds: 900,              // 60 … 86400
  maxAttempts: 5,

  code:  { length: 6,  alphabet: "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" },
  token: { length: 48, alphabet: "a-zA-Z0-9" },

  email: { product: "Acme", subject: ({ product }) => `Sign in to ${product}`, render },
  binding: { enabled: false },  // tie the link to the requesting browser
  rotation: { previous: [oldSecret] },
  maxSendsPerAddress: { count: 5, windowSeconds: 900 },
  rateLimiter,                  // any { check(key, now) }
  shouldSend: async (email) => await userExists(email),   // close sign-ups, no leak
  minimumStartDurationMs: 500,
});

Closing sign-ups without leaking who has an account is the common case for shouldSend:

shouldSend: async (email) => Boolean(await db.findUser(email)),

start returns the same result and takes the same time either way, so probing the endpoint reveals nothing.

Rotating the secret without invalidating live challenges:

{ secret: NEW, rotation: { previous: [OLD] } }   // drop OLD after one TTL

Errors

Every failure throws an LinkOtpError with a stable code, a suggested status, and a publicMessage that is safe to show a user.

import { LinkOtpError } from "@yasirdora/linkotp";

try {
  await auth.verifyCode({ email, code });
} catch (error) {
  if (LinkOtpError.is(error)) {
    error.code;               // "invalid_code" | "too_many_attempts" | …
    error.publicMessage;      // safe to render
    error.remainingAttempts;  // on invalid_code
  }
}

Codes: invalid_email, rate_limited, invalid_challenge, invalid_code, too_many_attempts, invalid_token, binding_mismatch, delivery_failed, configuration_error.

Expired, already-used, and never-existed all collapse into one error on purpose — distinguishing them tells an attacker holding a captured token whether it was ever valid.


Development

npm install     # typescript, plus better-auth to type and test the plugin against
npm test        # node:test, no runner needed
npm run build   # tsc only, dual ESM + CJS

No bundler, no test framework, no @types/node. The SQL suite runs against real SQLite via node:sqlite, and the Better Auth suite runs against Better Auth's own adapter rather than a hand-written double.


License

MIT