🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@nostr-wot/auth

Package Overview
Dependencies
Maintainers
1
Versions
1
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@nostr-wot/auth

Nostr authentication for HTTP servers — challenge / signed-event verify (NIP-98) / JWT. Framework-agnostic Web-standard handlers + Next.js App Router shim + client helper.

latest
Source
npmnpm
Version
0.2.0
Version published
Maintainers
1
Created
Source

@nostr-wot/auth

Drop-in Nostr authentication for HTTP servers. Stateless HMAC challenge → NIP-98 (kind 27235) signed-event verify → JWT. Mount four route handlers, get login.

EntryWhat's in it
@nostr-wot/authcreateAuthService, createHandlers (Web standard), low-level challenge/verify/JWT primitives
@nostr-wot/auth/nextcreateNextHandlers — Next.js App Router shim
@nostr-wot/auth/clientloginWithSigner, fetchMe, logout — drives the full flow on the client

Why

You don't want email + password on a Nostr app. NIP-98 lets clients prove ownership of a pubkey by signing a server-issued challenge. This package implements the server side: issue, verify, JWT. Stateless by default — no DB, no Redis, no per-instance memory.

Install

npm i @nostr-wot/auth nostr-tools

Quick start (Next.js App Router)

1. Create the service

// lib/auth.ts
import { createAuthService } from "@nostr-wot/auth";
import { createNextHandlers } from "@nostr-wot/auth/next";

export const auth = createAuthService({
  secret: process.env.NOSTR_AUTH_SECRET!,    // 32+ random bytes; HMAC + JWT
  challengeTtlSec: 300,                       // 5 min
  jwtTtlSec: 60 * 60 * 24 * 7,               // 1 week
  jwtIssuer: "https://myapp.com",
  expectedVerifyUrl: "https://myapp.com/api/auth/verify",
  // Optional: hook into a successful verify to add custom claims or
  // create/lookup a user row in your DB.
  async onVerify({ pubkey }) {
    const user = await db.user.upsert(pubkey);
    return { uid: user.id, role: user.role };
  },
});

export const handlers = createNextHandlers(auth, {
  cookie: true,                  // also set/clear the JWT as an HttpOnly cookie
  cookieName: "myapp_auth",      // default "nw_auth"
  cookieAttrs: { sameSite: "Lax", secure: true },
});

2. Mount the routes

// app/api/auth/challenge/route.ts
import { handlers } from "@/lib/auth";
export const POST = handlers.challenge;
// app/api/auth/verify/route.ts
import { handlers } from "@/lib/auth";
export const POST = handlers.verify;
// app/api/auth/me/route.ts
import { handlers } from "@/lib/auth";
export const GET = handlers.me;
// app/api/auth/logout/route.ts
import { handlers } from "@/lib/auth";
export const POST = handlers.logout;

That's it. Four files. <5min of work.

3. Drive the flow from the client

import { loginWithSigner, fetchMe, logout } from "@nostr-wot/auth/client";
import { Nip07Signer } from "@nostr-wot/signers";

const signer = new Nip07Signer();
const { jwt, pubkey } = await loginWithSigner({
  baseUrl: "/api/auth",
  signer,
});
// Cookie is now set; subsequent fetches with `credentials: 'include'` work.

const me = await fetchMe({ baseUrl: "/api/auth" });
// → { pubkey, uid, role, iat, exp, ... }

await logout({ baseUrl: "/api/auth" });

How it works

  • Challenge — server generates a random 16-byte nonce, packs it with a unix timestamp, HMAC-SHA256s the whole thing with secret. Returns <base64url(payload)>.<base64url(hmac)>. No state.
  • Verify — client signs a kind-27235 (NIP-98) event with ["challenge", "<the-string>"], ["u", "<verify-url>"], ["method", "POST"]. Server verifies the signature with nostr-tools, recomputes the HMAC, checks the timestamp against the TTL, validates u/method if configured, then issues a JWT.
  • JWT — HS256-signed via jose, claims include pubkey (subject) + anything onVerify returns. Optionally set as an HttpOnly cookie.
  • /me — reads the JWT off Authorization: Bearer … or the cookie, returns the payload.
  • /logout — clears the cookie. Pure header op.

The kind-27235 standard is NIP-98 (HTTP Auth). This package extends it with a challenge tag for replay-resistance.

Security notes

  • Secret rotation. When you change secret, all existing JWTs and pending challenges become invalid. Plan for it.
  • Challenge TTL. Default 5 minutes. Long enough for users to fumble through a hardware key prompt; short enough that a leaked challenge expires fast.
  • Skew tolerance. created_at on the signed event is checked within ±60s by default — tunable via skewSec. Tighten in lockstep with your server's clock guarantees.
  • expectedVerifyUrl. Strongly recommended in production. Without it, a client could reuse a verify event signed for a different origin (e.g. an attacker's site).
  • Cookie flags. Defaults: HttpOnly, SameSite=Lax, Path=/. Secure toggles on automatically when SameSite=None. For cross-subdomain flows, set cookieAttrs.domain.
  • Replay protection. The default flow is single-use in practice — the challenge string is freshly generated per call and the kind-27235 event includes a unique created_at. For strict no-replay guarantees (e.g. seeding the JWT from a queue worker), wire a per-challenge consume-once store via a wrapper around verifyChallenge.

Custom claims via onVerify

createAuthService({
  secret: process.env.NOSTR_AUTH_SECRET!,
  async onVerify({ pubkey, event }) {
    const user = await db.users.findUnique({ where: { pubkey } });
    if (!user || user.banned) throw new Error("forbidden");
    return {
      uid: user.id,
      role: user.role,
      tier: user.tier,
    };
  },
});

The returned object is merged into the JWT payload. Throwing aborts the login (returns 401 with reason: "hook_rejected").

Reading the JWT in server code

For RSC or server actions:

// app/dashboard/page.tsx (server component)
import { handlers } from "@/lib/auth";
import { headers } from "next/headers";

export default async function Dashboard() {
  const headerList = await headers();
  const req = new Request("http://x", { headers: headerList });
  const payload = await handlers.readJwt(req);
  if (!payload) redirect("/login");
  return <h1>Welcome {payload.pubkey}</h1>;
}

For middleware:

// middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";

export async function middleware(req: Request) {
  const cookie = req.headers.get("cookie")?.match(/nw_auth=([^;]+)/)?.[1];
  const payload = cookie ? await auth.verifyJwt(decodeURIComponent(cookie)) : null;
  if (!payload) return NextResponse.redirect("/login");
}

Other frameworks

createHandlers returns Web-standard (req: Request) => Promise<Response> functions. Mount in:

  • Honoapp.post("/auth/challenge", (c) => handlers.challenge(c.req.raw))
  • Bun.serveif (url.pathname === "/auth/challenge") return handlers.challenge(req)
  • Cloudflare Workers — same as above
  • DenoDeno.serve(handlers.challenge)

Lower-level primitives

For when you need to plug into a custom flow:

import {
  issueChallenge,
  verifyChallenge,
  verifyAuthEvent,
  signAuthJwt,
  verifyAuthJwt,
} from "@nostr-wot/auth";

const { challenge, expiresAt } = await issueChallenge(secret, 300);
const result = await verifyChallenge(challenge, secret, 300);
// → { ok, reason?, issuedAt? }

const verifyResult = await verifyAuthEvent(signedEvent, {
  secret,
  challengeTtlSec: 300,
  expectedUrl: "https://myapp.com/api/auth/verify",
  expectedMethod: "POST",
});

const jwt = await signAuthJwt({ pubkey, role: "admin" }, { secret });
const payload = await verifyAuthJwt(jwt, { secret });

License

MIT

Keywords

nostr

FAQs

Package last updated on 28 Apr 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts