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

@edyd/verdaccio-auth-oidc

v1.3.0

Published

Verdaccio auth plugin for generic OIDC/OAuth2 JWT verification

Readme

verdaccio-auth-oidc

A Verdaccio auth plugin that verifies JWTs from any OpenID Connect provider (Google, Azure AD/Entra, Okta, Keycloak, Auth0). Unauthenticated users are always rejected — there is no fallback or "allow if token is missing" behavior.

Features

Authentication

  • OIDC JWT verification via JWKS (auto-discovered from issuer)
  • Browser-based PKCE login flow with SPA support
  • Configurable username claim (email, sub, preferred_username)
  • Email domain restriction (allowed_domains) with email_verified enforcement
  • Group-based access control via IdP JWT claims (group_claim + allowed_groups)
  • Step-up authentication for sensitive operations (configurable step_up_max_age)
  • JWKS key caching with configurable TTL

API tokens

  • Long-lived vrd_-prefixed tokens for CI/CD and automation
  • Stored as SHA-256 hashes (raw token never persisted)
  • Configurable TTL (per-token and global max), max tokens per user
  • Token creation allowlist (by user or group; admins always bypass)
  • Optional least-privilege scope per token: read-only or publish limited to package patterns (enforced server-side; reads never restricted)
  • Deny-list kill switch blocks an identity from all auth and revokes its tokens
  • Groups snapshotted at creation for consistent authorization
  • Token epoch counter detects backup-restore and rejects stale tokens
  • CLI break-glass tool (verdaccio-revoke-tokens) for offline revocation

Package access control

  • Dynamic per-package/per-scope permissions (access, publish, unpublish)
  • Additive-only model — dynamic rules extend YAML config, never restrict it
  • Pattern matching: exact name, @scope/*, trailing wildcard, ** catch-all
  • HMAC-SHA256 tamper detection with separate key file
  • Monotonic epoch for rollback protection
  • Fail-closed on corrupt or unreadable store
  • Reserved principals ($all, $anonymous, $authenticated) blocked from dynamic add
  • Configurable limits (max_patterns, max_entries_per_action)

Admin

  • Admin role via config (admin_users, admin_groups) and dynamic ACL
  • List/revoke any user's tokens; cascade revocation on ACL removal
  • Manage package access rules (add/remove users, groups, patterns)
  • Uplink health dashboard
  • Audit log (token, ACL, and package access events; ring buffer, 200 entries per store)
  • Optional external audit sink (append-only file / syslog / webhook) for durable, compliance-grade records
  • Optional Prometheus metrics endpoint (aggregate counters; opt-in, auth-gated)

Security

  • HTTPS enforced on all mutation endpoints (bypassed only in dev_mode)
  • Timing-safe token comparison (timingSafeEqual)
  • Token store files written with mode 0600; startup warning if world-readable
  • Admin endpoints return 404 (not 403) to prevent enumeration
  • Generic error messages on auth failure (no config leakage)
  • Corrupt store quarantined, not auto-reset
  • Rate limiting / brute-force guard: per-IP throttle on sensitive endpoints and per-username lockout after repeated failed logins (default on)

Operational

  • Single-node file-based storage with proper-lockfile
  • Onboarding page at /-/oidc/setup (works without theme plugin)
  • User-facing permissions endpoint (/-/oidc/me/permissions, /-/oidc/me/pkg-access)
  • Optional download statistics (per-package totals + daily history; opt-in, durable, bounded)

How it works

The npm CLI sends credentials as username:password via HTTP Basic Auth. This plugin treats the password field as an OIDC JWT and:

  1. Fetches the provider's public keys from /.well-known/jwks.json
  2. Verifies the token signature, expiry, issuer, and optionally audience
  3. Extracts the username from a configurable claim (email, sub, or preferred_username)
  4. Optionally enforces email domain restrictions and group membership

Installation

# Copy to Verdaccio's plugin directory
mkdir -p /path/to/verdaccio/plugins/verdaccio-auth-oidc
cp -r build/* /path/to/verdaccio/plugins/verdaccio-auth-oidc/
cp package.json /path/to/verdaccio/plugins/verdaccio-auth-oidc/

# Install runtime dependencies
cd /path/to/verdaccio/plugins/verdaccio-auth-oidc
npm install --omit=dev --ignore-scripts

# Restart Verdaccio

Version compatibility: install the same version of @edyd/verdaccio-auth-oidc and @edyd/verdaccio-theme-oidc. They are released in lockstep; the theme's admin UI calls auth-plugin endpoints, so mismatched versions can break the admin views.

Configuration

Add to your Verdaccio config.yaml:

plugins: /path/to/verdaccio/plugins

auth:
  auth-oidc:
    issuer: 'https://accounts.google.com'
    audience: 'your-client-id' # optional
    allowed_domains: # optional — restrict by email domain
      - mycompany.com # exact match
      - '*.mycompany.com' # wildcard — any subdomain (not the bare apex)
    group_claim: groups # optional — JWT claim containing group array (dot-path ok, e.g. realm_access.roles)
    allowed_groups: # optional — require membership in at least one
      - npm-publishers
    username_claim: email # email | sub | preferred_username (default: email)
    jwks_cache_ttl: 3600 # JWKS cache lifetime in seconds (default: 3600)
    metrics: # optional — Prometheus metrics endpoint (disabled by default)
      enabled: true
      token: 'a-long-random-scrape-secret' # optional static bearer for scrapers

| Option | Required | Default | Description | | ----------------- | -------- | ------- | ------------------------------------------------------------------------------------------------ | | issuer | Yes | — | OIDC issuer URL (must serve /.well-known/jwks.json) | | audience | No | — | Expected aud claim value | | allowed_domains | No | — | Allowed email domains. Exact (co.com) or wildcard (*.co.com = any subdomain, excludes apex) | | group_claim | No | — | JWT claim with the user's groups (array). Dot-paths for nested claims, e.g. realm_access.roles | | allowed_groups | No | — | Require membership in at least one of these groups (requires group_claim) | | username_claim | No | email | Which JWT claim to use as the Verdaccio username | | jwks_cache_ttl | No | 3600 | How long to cache JWKS keys (seconds) | | metrics | No | — | Prometheus metrics endpoint (enabled, optional token); see Metrics below |

Provider examples

Google

auth:
  auth-oidc:
    issuer: 'https://accounts.google.com'
    audience: '123456789.apps.googleusercontent.com'
    allowed_domains: ['mycompany.com']
    username_claim: email

Azure AD / Entra

auth:
  auth-oidc:
    issuer: 'https://login.microsoftonline.com/{tenant-id}/v2.0'
    audience: 'api://my-verdaccio-app'
    group_claim: groups
    allowed_groups: ['npm-publishers-group-id']
    username_claim: preferred_username

Okta

auth:
  auth-oidc:
    issuer: 'https://your-org.okta.com/oauth2/default'
    audience: 'my-verdaccio-client-id'
    allowed_domains: ['yourcompany.com']
    group_claim: groups
    allowed_groups: ['npm-developers']
    username_claim: email

Keycloak

Keycloak nests realm roles under realm_access.roles, so use a dot-path for group_claim:

auth:
  auth-oidc:
    issuer: 'https://keycloak.example.com/realms/myrealm'
    audience: 'verdaccio'
    group_claim: realm_access.roles # nested claim
    allowed_groups: ['npm-publishers']
    username_claim: email

Onboarding middleware (optional)

The plugin can also serve a web-based setup page at /-/oidc/setup that guides users through authentication. Add a middlewares section to enable it:

middlewares:
  auth-oidc:
    enabled: true
    client_id: 'your-oidc-client-id' # optional — enables browser-based PKCE login
    client_secret: 'your-secret' # optional — required by some providers for token exchange
    scopes: 'openid email profile' # optional — OAuth scopes to request
    external_url: 'https://your-verdaccio-host' # optional — public URL for PKCE redirects

| Option | Required | Default | Description | | --------------- | -------- | ---------------------- | -------------------------------------------------------- | | enabled | No | true | Set false to disable; omitting enables the middleware | | client_id | No | — | OIDC client ID for browser-based PKCE login | | client_secret | No | — | OIDC client secret (required by some providers) | | scopes | No | openid email profile | OAuth scopes to request during PKCE flow | | external_url | No | — | Public registry URL for PKCE redirect URIs (recommended) |

Security: When client_id is set without external_url, redirect URLs are derived from request headers, which can be spoofed behind misconfigured proxies. Always set external_url in production.

Routes

| Route | Method | Description | | ------------------ | ------ | ----------------------------------------------------------------- | | /-/oidc/config | GET | Returns provider name, issuer, and PKCE availability as JSON | | /-/oidc/setup | GET | Onboarding page with token paste, PKCE login, CLI help | | /-/oidc/validate | POST | Accepts { "token": "..." }, returns username + groups | | /-/oidc/login | GET | Initiates PKCE flow (client_id required); ?mode=spa for theme | | /-/oidc/callback | GET | Handles OIDC redirect; SPA mode returns fragment redirect |

PKCE prerequisites

If you enable browser login (client_id), register https://your-verdaccio-host/-/oidc/callback as a redirect URI in your OIDC provider's application settings.

API tokens (optional)

The plugin supports long-lived API tokens that can be used in CI/CD pipelines or environments where interactive OIDC login is impractical. API tokens use the vrd_ prefix and are stored as SHA-256 hashes.

Enable API tokens by adding api_tokens to your auth config:

auth:
  auth-oidc:
    issuer: 'https://accounts.google.com'
    username_claim: email
    api_tokens:
      enabled: true
      max_ttl_days: 90 # maximum token lifetime
      default_ttl_days: 30 # default when ttl_days not specified
      max_per_user: 10 # active tokens per user
      step_up_max_age: 300 # max age (seconds) of OIDC auth for sensitive ops
      dev_mode: false # true allows HTTP issuers on loopback (dev only)
      admin_users: # users with admin privileges
        - '[email protected]'
      admin_groups: # groups with admin privileges
        - 'registry-admins'
      allowed_users: # restrict token creation to these users
        - '[email protected]'
      allowed_groups: # restrict token creation to these groups
        - 'npm-publishers'
      denied_users: # kill switch: block these users from all auth
        - '[email protected]'

| Option | Required | Default | Description | | ------------------ | -------- | ------- | ------------------------------------------------------- | | enabled | Yes | — | Enable API token management | | max_ttl_days | No | 90 | Maximum token lifetime in days | | default_ttl_days | No | 30 | Default lifetime when ttl_days is not specified | | max_per_user | No | 10 | Maximum number of active tokens per user | | step_up_max_age | No | 300 | Max age (seconds) of OIDC auth for sensitive operations | | dev_mode | No | false | Allow HTTP issuers on loopback addresses (dev only) | | admin_users | No | — | Email addresses with admin privileges | | admin_groups | No | — | Group names with admin privileges | | allowed_users | No | — | Restrict token creation to these email addresses | | allowed_groups | No | — | Restrict token creation to these group names | | denied_users | No | — | Block these email addresses from all authentication |

Token creation mode: When any allowed_users or allowed_groups are configured, token creation switches to allowlist mode. Only listed users/groups (plus admins) can create tokens. Without an allowlist, any authenticated user can create tokens.

Scoped (least-privilege) tokens

Tokens can be created with an optional scope that narrows what they may do. Scope only ever reduces privileges below the creating user's; it never grants anything extra. There are two scope shapes (mutually exclusive):

  • Read-only ({ "readonly": true }): the token can install/read any package the user could, but is rejected for all publish/unpublish operations.
  • Package-scoped ({ "packages": ["@acme/*", "tool-*"] }): publish and unpublish are limited to packages matching one of the patterns. Reads are not restricted — this is deliberate, because restricting reads would break npm install (which fetches many transitive dependencies). Patterns use the same syntax as dynamic ACLs: exact names, @scope/*, and trailing prefix-* (bare * is rejected; use ** for catch-all). Up to 20 patterns per token.

Example request body for POST /-/oidc/tokens:

{ "name": "acme deploy", "ttl_days": 30, "scope": { "packages": ["@acme/*"] } }

Omitting scope (or sending { "readonly": false } with no packages) creates a full-access token, preserving existing behavior. Scope is enforced server-side in allow_publish/allow_unpublish, independent of the UI, by threading the token's scope through the authenticated identity — it cannot be bypassed by a crafted client. The theme UI exposes this as a Permissions selector (Full access / Read-only / Publish only to specific packages) on the token creation form, and shows a read-only or scoped badge on scoped tokens.

Deny-list (kill switch): denied_users blocks an identity from all authentication — browser/JWT login and API tokens — regardless of allow or admin status. It is the fastest way to cut off a compromised or offboarded account. Admins can also manage the deny-list at runtime via the API or theme UI (POST/DELETE /-/oidc/admin/acl/denied-users); adding a user there also immediately revokes their active API tokens. Entries are matched by canonical email, so use username_claim: email for reliable matching. You cannot deny your own account, and a config-defined admin listed in denied_users will be locked out (the plugin warns about this at startup).

Session scope: the deny check runs on every API-token and OIDC bearer request, so CLI/CI access is cut off immediately. Existing browser sessions backed by Verdaccio's own short-lived JWT clear when that token expires.

Token API endpoints

All mutation endpoints require a valid OIDC Bearer token and HTTPS (unless dev_mode). Read-only endpoints (GET /me/permissions, GET /tokens) enforce Bearer auth but not HTTPS.

| Route | Method | Auth | Description | | ----------------------------------------- | ------ | ----- | --------------------------------- | | /-/oidc/tokens | POST | User | Create a new API token | | /-/oidc/tokens | GET | User | List own tokens | | /-/oidc/tokens/:id | DELETE | User | Revoke own token | | /-/oidc/tokens/admin/all | GET | Admin | List all tokens (all users) | | /-/oidc/tokens/user/:username | DELETE | Admin | Revoke all tokens for a user | | /-/oidc/admin/tokens/:username/:tokenId | DELETE | Admin | Revoke a specific user's token | | /-/oidc/me/permissions | GET | User | Check own permissions and status | | /-/oidc/me/pkg-access | GET | User | View own effective package access |

ACL management endpoints

When API tokens are enabled, admins can manage access control lists via the API. ACL entries added via the API are stored in .verdaccio-acl.json alongside the Verdaccio storage directory and merged with YAML config entries.

| Route | Method | Auth | Description | | -------------------------------- | ------ | ----- | ---------------------------------------------------------- | | /-/oidc/admin/acl | GET | Admin | Get merged ACL with origin flags | | /-/oidc/admin/acl/:list | POST | Admin | Add entry (admin-users, allowed-users, denied-users) | | /-/oidc/admin/acl/:list/:value | DELETE | Admin | Remove entry; cascades token revocation | | /-/oidc/admin/audit | GET | Admin | Unified audit log with filters + paging | | /-/oidc/admin/uplinks | GET | Admin | Proxy health dashboard (probes uplink URLs) |

Audit log: GET /-/oidc/admin/audit merges ACL and package-access events into one feed. Query params: action (exact), actor (case-insensitive substring), from/to (ms epoch, inclusive), offset, limit (default 50, max 200). Response: { entries, total, offset, limit, actions } — newest first, where actions lists every action name available for filtering. Backed by a per-store 200-entry ring buffer (durable external sink is a separate roadmap item).

Metrics

Set metrics.enabled: true (opt-in) to expose an in-process metrics snapshot.

| Route | Method | Auth | Description | | ----------------------- | ------ | ------------- | ------------------------------------ | | /-/oidc/metrics | GET | Token / Admin | Prometheus text exposition | | /-/oidc/admin/metrics | GET | Admin | JSON snapshot (used by the admin UI) |

Exposed series (aggregate counters — no usernames or token data): oidc_auth_success_total{method}, oidc_auth_failure_total{reason}, oidc_tokens_created_total, oidc_tokens_revoked_total, oidc_jwks_cache_hits_total, oidc_jwks_cache_misses_total, oidc_active_tokens (gauge), oidc_uptime_seconds (gauge).

Security: the endpoint is off by default. /-/oidc/metrics requires either a static bearer token (metrics.token, for scrapers like Prometheus — compared in constant time) or an admin OIDC bearer (used by the admin UI). HTTPS is enforced (except in dev_mode), so the scrape token is never sent in the clear. Counters are aggregate-only and reset on restart (use rate()/increase()).

metrics.token is optional. Omit it for UI-only use — the admin dashboard authenticates with your admin OIDC bearer. Set it only when an external scraper (which can't perform an OIDC login) needs access. Do not commit a real token. Generate one with openssl rand -hex 32 and inject it at deploy time (secret manager, Kubernetes Secret, or a templated config rendered at container start); Verdaccio does not reliably substitute ${ENV} placeholders in config values.

Example Prometheus scrape config:

scrape_configs:
  - job_name: verdaccio-oidc
    scheme: https
    metrics_path: /-/oidc/metrics
    authorization:
      credentials: 'a-long-random-scrape-secret' # matches metrics.token
    static_configs:
      - targets: ['registry.example.com']

CLI: revoke-tokens

A CLI tool for emergency token revocation (break-glass scenarios). Works directly on the token store files without requiring a running Verdaccio instance.

# List all active tokens
verdaccio-revoke-tokens --storage /path/to/verdaccio/storage --list

# Revoke all tokens for a user
verdaccio-revoke-tokens --storage /path/to/verdaccio/storage --user [email protected]

# Revoke a specific token by ID
verdaccio-revoke-tokens --storage /path/to/verdaccio/storage --token-id <uuid>

# Compact the store, removing expired/revoked tokens past the grace window
verdaccio-revoke-tokens --storage /path/to/verdaccio/storage --purge-expired

| Flag | Short | Description | | ------------------ | ----- | ---------------------------------------------------------- | | --storage <path> | -s | Path to Verdaccio storage directory (required) | | --user <email> | -u | Revoke all tokens for this user | | --token-id <id> | — | Revoke a specific token by ID | | --list | — | List all active tokens | | --purge-expired | — | Remove expired/revoked tokens past the 30-day grace window | | --help | -h | Show help |

The runtime plugin auto-purges every 6 hours; --purge-expired is for manual offline compaction (e.g. break-glass cleanup without a running Verdaccio).

Custom OIDC theme (optional)

For a fully integrated experience, install the companion theme plugin verdaccio-theme-oidc. This replaces the default Verdaccio UI login with a native OIDC flow — users click "Sign in with {Provider}" and are redirected to your OIDC provider.

theme:
  oidc: {}

middlewares:
  auth-oidc:
    enabled: true
    client_id: 'your-oidc-client-id' # Required for browser login

The theme provides:

  • OIDC login page with provider auto-detection (Google, Azure AD, Okta, etc.)
  • Settings page with .npmrc snippets, copy buttons, and token expiry display
  • API token management UI (create, list, revoke)
  • Admin dashboard with ACL management and uplink health monitoring
  • Silent session restoration on page refresh
  • Token expiry indicators in the header

See the verdaccio-theme-oidc package for details.

Fallback: adding a link to the default Verdaccio UI

If you don't use the custom theme, you can inject a link into the default UI:

web:
  scriptsBodyAfter:
    - '<script>document.addEventListener("DOMContentLoaded",()=>{const b=document.createElement("div");b.innerHTML="<a href=\"/-/oidc/setup\" style=\"position:fixed;bottom:16px;right:16px;padding:8px 16px;background:#4b5e40;color:#fff;border-radius:4px;text-decoration:none;font-size:14px;z-index:9999\">OIDC Setup</a>";document.body.appendChild(b)})</script>'

Usage

Once configured, users authenticate by passing their OIDC token as the npm password:

npm --registry https://your-verdaccio-host login
# Username: [email protected]
# Password: <paste your OIDC/JWT token>

Alternatively, visit https://your-verdaccio-host/-/oidc/setup for an interactive setup guide.

How to obtain a token depends on your provider:

| Provider | Command | | ----------- | ------------------------------------------------------------------------------- | | Google | gcloud auth print-identity-token | | Azure | az account get-access-token --query accessToken -o tsv | | Okta (PKCE) | Use the PKCE helper: node tools/get-token.mjs --issuer <url> --client-id <id> |

Package access management (optional)

Manage Verdaccio's per-package access/publish/unpublish permissions from the Admin UI. Config entries remain immutable; the UI can only add users or groups dynamically (additive-only model).

Enable package access management by adding pkg_access to your auth config:

auth:
  auth-oidc:
    issuer: 'https://accounts.google.com'
    username_claim: email
    pkg_access:
      enabled: true
      allow_dynamic_patterns: false # allow creating patterns not in YAML
      allow_catchall_dynamic: false # allow dynamic entries on '**'
      max_patterns: 100 # max dynamic patterns
      max_entries_per_action: 50 # max entries per access/publish/unpublish

| Option | Default | Description | | ------------------------ | ------- | --------------------------------------------------- | | enabled | false | Enable dynamic package access management | | allow_dynamic_patterns | false | Allow creating patterns not present in YAML config | | allow_catchall_dynamic | false | Allow dynamic entries on the ** catch-all pattern | | max_patterns | 100 | Maximum number of dynamic-only patterns | | max_entries_per_action | 50 | Maximum dynamic users/groups per action per pattern |

Recommended config for internal registries

For a private registry hosting confidential packages, use restrictive base rules in config and expand from the UI:

packages:
  '@myco/secret-*':
    access: [email protected] security-team
    publish: [email protected]
  '@myco/*':
    access: engineering-team
    publish: engineering-team
  '**':
    access: $authenticated
    publish: engineering-team
    proxy: npmjs

Admins can then add individual users to @myco/secret-* or @myco/* from the Package Access section in the Admin UI without restarting Verdaccio.

Security model

  • Config is the trust anchor. YAML entries cannot be removed via API/UI.
  • Additive-only. Dynamic rules extend config permissions; they cannot restrict what config already allows.
  • Reserved principals blocked. $all, $anonymous, and $authenticated cannot be added dynamically.
  • Fail-closed. If the dynamic store is corrupt or unreadable, only config rules apply. No fail-open.
  • Tamper detection. The store is signed with HMAC-SHA256 using a separate key file (.verdaccio-pkg-access.key, auto-generated, 0600 permissions). A monotonic epoch counter detects rollback attempts at runtime.
  • Step-up auth required for all mutations.
  • Single-node assumption. File-based storage with file locking is safe for a single Verdaccio instance. Multi-instance deployments with shared storage would need a different backend.

Admin API endpoints

| Route | Method | Description | | ----------------------------------- | ------ | -------------------------------------------- | | /-/oidc/admin/pkg-access | GET | Merged rules (config + dynamic) | | /-/oidc/admin/pkg-access/entries | POST | Add user/group: { pattern, action, value } | | /-/oidc/admin/pkg-access/entries | DELETE | Remove dynamic entry | | /-/oidc/admin/pkg-access/patterns | POST | Create new pattern (requires opt-in) | | /-/oidc/admin/pkg-access/patterns | DELETE | Delete dynamic-only pattern |

Security considerations

  • HTTPS required: All API token endpoints enforce HTTPS unless dev_mode is enabled. Verdaccio must sit behind exactly one trusted reverse proxy (ALB, nginx, etc.) that sets X-Forwarded-Proto.
  • Audience validation: Omitting audience means tokens from any client sharing the issuer will be accepted. Always set audience in production.
  • Step-up auth: Creating or revoking tokens requires a fresh OIDC authentication (within step_up_max_age seconds).
  • Token epoch: Revoking tokens bumps a monotonic epoch counter. If the epoch file is ahead of the store (e.g., after a backup restore), all tokens are rejected until resolved via the CLI.
  • File permissions: The token store is written with mode 0600. The plugin warns at startup if the file is world/group-readable.

Rate limiting / brute-force guard

The plugin includes an in-process rate limiter (no external store/Redis) with two layers, enabled by default:

  • Per-IP endpoint throttle on POST /validate, GET /login, the GET /metrics scrape, and every authenticated endpoint (the throttle runs inside the auth guard, before verifyToken, so an unauthenticated flood cannot drive JWKS fetches / signature checks). Over the limit returns 429 with a Retry-After header. Transient only — no extended lockout. Relies on trust proxy (set automatically whenever rate limiting, API tokens, or metrics are enabled) so req.ip is the real client behind your reverse proxy. Without a single trusted proxy in front, X-Forwarded-For can be spoofed and per-IP limiting bypassed — see the deployment requirement below.
  • Per-username failure lockout on the CLI/basic-auth path (authenticate). After auth_max_failures failed attempts within the window, that username is locked for lockout_ms. A successful login clears the counter. The auth callback has no request IP, so this layer is keyed by username.

Configure under auth.auth-oidc.rate_limit:

auth:
  auth-oidc:
    rate_limit:
      enabled: true # default true; set false to disable both layers
      window_ms: 60000 # sliding window length (default 60s)
      max_requests: 120 # per-IP requests/window for sensitive endpoints
      auth_max_failures: 10 # per-username failed logins before lockout
      lockout_ms: 900000 # lockout duration once threshold hit (default 15m)

| Option | Default | Description | | ------------------- | -------- | --------------------------------------------------- | | enabled | true | Master switch for both layers | | window_ms | 60000 | Sliding-window length (ms) | | max_requests | 120 | Per-IP requests per window on sensitive endpoints | | auth_max_failures | 10 | Per-username failed logins within the window | | lockout_ms | 900000 | Lockout duration after the failure threshold is hit |

Targeted-lockout trade-off: because the authenticate path exposes no client IP, the failure lockout is keyed by username. An attacker who knows a username could deliberately trip the lockout to deny that user (for the lockout_ms window). Defaults are conservative (10 failures, 15-minute lockout, auto-cleared on success). Tune auth_max_failures/lockout_ms, or set enabled: false, if this trade-off is unacceptable for your threat model. State is per-process and resets on restart; key cardinality is bounded to cap memory under spoofed-key abuse. Rejections are counted in the oidc_rate_limited_total metric.

External audit sink

Audit events (token create/revoke, ACL changes, package-access changes) live in a 200-entry in-store ring buffer — fine for the admin UI, but lost on restart and unsuitable for compliance. An optional audit sink forwards every audited event to a durable destination. It hooks the same appendAuditLog choke point the ring buffer uses, so coverage is automatic.

Each event is a JSON object: { action, actor, target, ts, source } where source is acl or pkg-access (revoked_count is included when relevant).

Configure under auth.auth-oidc.audit_sink (omit the block to disable):

auth:
  auth-oidc:
    # ── File: append-only JSONL (one event per line) ──
    audit_sink:
      type: file
      path: /var/log/verdaccio/audit.jsonl # absolute path; file written 0600


    # ── Syslog: UDP RFC 5424 datagrams ──
    # audit_sink:
    #   type: syslog
    #   host: 127.0.0.1
    #   port: 514
    #   facility: 13          # default 13 (log audit)
    #   app_name: verdaccio-oidc

    # ── Webhook: HTTP POST, buffered + retried ──
    # audit_sink:
    #   type: webhook
    #   url: https://siem.example.com/ingest   # HTTPS required (http:// loopback only with dev_mode)
    #   secret: "<hmac-key>"  # optional; signs body as X-Audit-Signature: sha256=<hex>
    #   max_queue: 1000       # events buffered before drop-oldest
    #   max_retries: 5        # delivery attempts per event (exponential backoff)
    #   timeout_ms: 5000      # per-request timeout

| Sink | Durability | Notes | | --------- | --------------------------- | ------------------------------------------------------ | | file | Disk (survives restart) | JSONL append, 0600. You manage rotation (logrotate). | | syslog | Depends on collector | UDP, fire-and-forget; pair with a reliable collector. | | webhook | At-least-once (best effort) | In-memory queue; HMAC-signed; lost on crash. |

Design guarantees:

  • Never blocks or breaks a request. Delivery is async and failure-isolated; a slow/down receiver never stalls a publish, login, or ACL change.
  • Bounded memory. The webhook queue caps at max_queue and drops the oldest event on overflow (counted as dropped).
  • No process keep-alive. Sockets and retry timers are unref'd.
  • Multi-instance: each node forwards independently. For a single aggregated stream, point all nodes at one syslog/webhook collector (file sinks are per-node).
  • Secrets: token values are never logged (already stored hashed); events carry only IDs/usernames/actions.

The webhook payload is the raw audit JSON ({ action, actor, target, ts, source, app_name }). This works directly with log collectors / SIEMs that accept arbitrary JSON (Splunk HEC, Datadog, Elastic, or your own endpoint).

Chat integrations (Slack, Teams, etc.): these expect a service-specific body (e.g. Slack requires { "text": "..." }) and will reject the raw audit JSON with 400. Point the webhook at a small adapter that reshapes the event, rather than at the chat provider directly:

verdaccio  ──POST audit JSON──▶  adapter (Lambda / Cloud Function / tiny service)
                                   │ maps → { "text": "..." } (+ verifies HMAC)
                                   ▼
                                 Slack / Teams incoming webhook

The adapter can also verify the X-Audit-Signature HMAC (chat providers ignore it) and apply provider rate-limiting so a burst of events doesn't get dropped.

Delivery outcomes are exported as the oidc_audit_sink_total{outcome=...} metric (delivered / failed / dropped).

Download statistics

Opt-in per-package download counts with daily history. When enabled, the plugin tallies successful tarball fetches (GET /<pkg>/-/<file>.tgz, status 200 served or 304 client-cache hit) and the theme's package page shows an all-time total plus a 30-day bar chart.

Configure under auth.auth-oidc.download_stats (omit the block to disable):

auth:
  auth-oidc:
    download_stats:
      enabled: true
      retention_days: 90 # daily buckets older than this are pruned (default 90)
      max_packages: 5000 # lowest-total packages evicted past this (default 5000)

Counting is a pure in-memory increment on the serve path (no added latency). Counts accumulate as deltas and are merged into .verdaccio-download-stats.json on a debounced timer; the read-modify-write runs under a lock and applies deltas additively, so multiple processes sharing one storage dir don't clobber each other. Memory and disk are bounded by retention_days and max_packages.

Endpoints:

  • GET /-/oidc/downloads/:pkg — any authenticated user; :pkg is URL-encoded. Optional ?days=N (1–365, default 90). Returns { package, total, series }.
  • GET /-/oidc/admin/downloads — admin only; ?limit=N (1–500, default 50). Returns the top-N packages by all-time total. Hidden (404) from non-admins.

A process-lifetime oidc_downloads_total counter is also exported via the metrics endpoint. Only package names and counts are stored — no per-user data.

Event webhooks

Opt-in outbound HTTP notifications on security-relevant events (token, ACL, and package-access changes). Unlike audit_sink (one durable destination for every event), event webhooks fan out to many subscriptions, each filtered to the events it cares about — and admins can manage them at runtime from the theme's Settings → Admin → Event Webhooks page (no restart).

Configure under auth.auth-oidc.event_webhooks (omit the block to disable):

auth:
  auth-oidc:
    event_webhooks:
      enabled: true
      max_subscriptions: 50 # cap on dynamic (API-created) subs (default 50)
      max_queue: 1000 # per-sub buffered events before drop-oldest (default 1000)
      max_retries: 5 # delivery attempts per event (default 5)
      timeout_ms: 5000 # per-request timeout (default 5000)
      ssrf_protection: true # block dynamic targets on private/internal IPs (default true)
      # allowed_hosts: ['hooks.slack.com'] # if set, dynamic subs may ONLY use these hosts
      # Optional config-seeded subscriptions (immutable via API/UI):
      subscriptions:
        - url: https://siem.example.com/verdaccio
          events: ['*'] # or ['acl:*', 'pkg-access:*', 'token_created', ...]
          secret: ${WEBHOOK_SECRET} # optional HMAC-SHA256 signing key
        # Slack incoming webhook via headers + body template:
        - url: https://hooks.slack.com/services/T000/B000/XXXX
          events: ['acl:*', 'token_revoked']
          headers:
            Content-Type: application/json
          template: '{"text":"verdaccio: {{actor}} did {{action}} on {{target}}"}'

Event selectors. A subscription targets events with * (all), <source>:* (acl:* or pkg-access:*), or an exact audit action (token_created, token_revoked, admin_revoke_all, admin_revoke_token, add_admin_users, remove_admin_users, add_allowed_users, remove_allowed_users, add_denied_users, remove_denied_users, pkg_access_add, pkg_access_remove, pkg_pattern_create, pkg_pattern_delete). Auth/login events are not sent — only durable change events that flow through the audit stream.

Payload + signing. By default the body is the raw audit JSON ({ action, actor, target, ts, source }, plus revoked_count on deny/revoke events). When a subscription has a secret, the body is signed as X-Webhook-Signature: sha256=<hmac> (verify it the same way as audit_sink's X-Audit-Signature). The signature is computed over the final (templated) body.

Custom headers. A subscription may attach static request headers (e.g. a provider auth token). Header values are write-only — the API/UI return only the header names. Header names must be tokens ([A-Za-z0-9-]); values may not contain CR/LF or control chars (header-injection guard); Host, Content-Length, and X-Webhook-Signature are reserved and rejected.

Body templates (Slack/Teams). Set template to reshape the payload without a separate adapter. Placeholders {{action}}, {{actor}}, {{target}}, {{source}}, {{ts}}, {{revoked_count}} are substituted; every value is JSON-escaped, so a hostile package name or actor cannot break out of a JSON string literal and inject structure. Put placeholders inside JSON string literals ({"text":"{{actor}}"}). Leave template unset to send the raw audit JSON.

SSRF protection. Because admins create dynamic subscriptions at runtime, the HTTPS scheme check alone wouldn't stop a target pointed at an internal address (cloud metadata 169.254.169.254, RFC-1918 hosts, etc.). With ssrf_protection: true (the default), each dynamic target is resolved and rejected if any address falls in a private, loopback, link-local, CGNAT, or otherwise non-public range — both at creation/update and again immediately before every delivery (to blunt DNS-rebinding). Loopback is permitted only when api_tokens.dev_mode is on. Config-seeded subscriptions are operator-trusted and exempt. To allow specific internal receivers, list them in allowed_hosts: when set, dynamic subs may target only those hostnames and the IP-range check is skipped for them. Residual risk: there is a small TOCTOU window between the pre-flight resolve and the HTTP client's own resolution; for hard guarantees pair this with allowed_hosts or egress firewalling. Set ssrf_protection: false to disable entirely (not recommended in production).

Guarantees. Delivery is non-blocking and failure-isolated: a slow or down receiver never delays a token/ACL change. Each subscription has its own buffered, retried worker (exponential backoff, drop-oldest past max_queue); sockets and timers are unref'd. Secrets are stored on disk (0600) but never returned by the API — the UI only shows whether one is set.

Endpoints (all admin-only; mutations require fresh OIDC auth, like ACL changes):

  • GET /-/oidc/admin/webhooks — list subscriptions + available selectors.
  • POST /-/oidc/admin/webhooks — create a dynamic subscription.
  • PATCH /-/oidc/admin/webhooks/:id — update a dynamic subscription. Omitted fields are unchanged; null/"" clears. secret/headers are write-only (re-send to change, blank keeps the stored value); template is sent in full.
  • POST /-/oidc/admin/webhooks/:id/enabled — enable/disable.
  • DELETE /-/oidc/admin/webhooks/:id — remove (dynamic only).
  • POST /-/oidc/admin/webhooks/:id/test — send a signed test ping (uses the subscription's headers + template).

Delivery outcomes are exported as oidc_webhook_total{outcome=...} (delivered / failed / dropped).

License

MIT