@edyd/verdaccio-auth-oidc
v1.3.0
Published
Verdaccio auth plugin for generic OIDC/OAuth2 JWT verification
Maintainers
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) withemail_verifiedenforcement - 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:
- Fetches the provider's public keys from
/.well-known/jwks.json - Verifies the token signature, expiry, issuer, and optionally audience
- Extracts the username from a configurable claim (
email,sub, orpreferred_username) - 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 VerdaccioVersion compatibility: install the same version of
@edyd/verdaccio-auth-oidcand@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
auth:
auth-oidc:
issuer: 'https://accounts.google.com'
audience: '123456789.apps.googleusercontent.com'
allowed_domains: ['mycompany.com']
username_claim: emailAzure 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_usernameOkta
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: emailKeycloak
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: emailOnboarding 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_idis set withoutexternal_url, redirect URLs are derived from request headers, which can be spoofed behind misconfigured proxies. Always setexternal_urlin 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_usersorallowed_groupsare 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 breaknpm install(which fetches many transitive dependencies). Patterns use the same syntax as dynamic ACLs: exact names,@scope/*, and trailingprefix-*(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_usersblocks 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 useusername_claim: emailfor reliable matching. You cannot deny your own account, and a config-defined admin listed indenied_userswill 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/auditmerges 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, whereactionslists 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/metricsrequires 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 indev_mode), so the scrape token is never sent in the clear. Counters are aggregate-only and reset on restart (userate()/increase()).
metrics.tokenis 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 withopenssl rand -hex 32and 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-expiredis 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 loginThe theme provides:
- OIDC login page with provider auto-detection (Google, Azure AD, Okta, etc.)
- Settings page with
.npmrcsnippets, 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: npmjsAdmins 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$authenticatedcannot 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,0600permissions). 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_modeis enabled. Verdaccio must sit behind exactly one trusted reverse proxy (ALB, nginx, etc.) that setsX-Forwarded-Proto. - Audience validation: Omitting
audiencemeans tokens from any client sharing the issuer will be accepted. Always setaudiencein production. - Step-up auth: Creating or revoking tokens requires a fresh OIDC
authentication (within
step_up_max_ageseconds). - 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, theGET /metricsscrape, and every authenticated endpoint (the throttle runs inside the auth guard, beforeverifyToken, so an unauthenticated flood cannot drive JWKS fetches / signature checks). Over the limit returns429with aRetry-Afterheader. Transient only — no extended lockout. Relies ontrust proxy(set automatically whenever rate limiting, API tokens, or metrics are enabled) soreq.ipis the real client behind your reverse proxy. Without a single trusted proxy in front,X-Forwarded-Forcan be spoofed and per-IP limiting bypassed — see the deployment requirement below. - Per-username failure lockout on the CLI/basic-auth path (
authenticate). Afterauth_max_failuresfailed attempts within the window, that username is locked forlockout_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
authenticatepath 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 thelockout_mswindow). Defaults are conservative (10 failures, 15-minute lockout, auto-cleared on success). Tuneauth_max_failures/lockout_ms, or setenabled: 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 theoidc_rate_limited_totalmetric.
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_queueand drops the oldest event on overflow (counted asdropped). - 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 webhookThe 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;:pkgis 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/headersare write-only (re-send to change, blank keeps the stored value);templateis 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
