Publish an NPM package to the registry. Handles authentication including 2FA and security key (WebAuthn) flows.
prepublishOnly scriptbash
npm run build:hooks # or whatever build step exists
npm test # ensure tests pass
bash
npm whoami
E401 → need to authenticate, continue to Step 3.If the user has a password manager CLI, retrieve credentials:
rbw (Bitwarden):
bash
rbw get npmjs.com # returns password
rbw get --full npmjs.com # returns username + password + URIs
Other password managers — ask the user how to retrieve their NPM credentials:
pass: pass show npmjs.com1password: op item get "npmjs.com" --fields label=username,label=passwordgopass: gopass show npmjs.comExtract:
NPM requires browser-based authentication when 2FA or security keys are enabled. Use the user's default browser, not a test/headless browser.
bash
npm login
This prints a URL like:
text
https://www.npmjs.com/login?next=/login/cli/<uuid>
bash
open "<the-login-url>"
On macOS, open uses the user's default browser with all their existing sessions, cookies, and extensions — including security key/WebAuthn support.
If the user is NOT already logged into npmjs.com in their browser, use Playwright with the user's Chrome profile to fill credentials:
bash
npx playwright install chromium # first time only
Then run a script like:
javascript
import { chromium } from 'playwright';
import { writeFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { execSync } from 'child_process';
// Get credentials from password manager or user
const NPM_USER = '<username from password manager>';
const NPM_PASS = '<password from password manager>';
// Use user's Chrome — NOT headless, NOT test browser
// channel: 'chrome' uses the system Chrome installation
const browser = await chromium.launch({ headless: false, channel: 'chrome' });
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://www.npmjs.com/login');
await page.waitForLoadState('networkidle');
// Fill username
const usernameInput = page.locator('input#username, input[name="username"], input[type="text"]').first();
await usernameInput.waitFor({ timeout: 10000 });
await usernameInput.fill(NPM_USER);
// Fill password
const passwordInput = page.locator('input#password, input[name="password"], input[type="password"]').first();
await passwordInput.waitFor({ timeout: 5000 });
await passwordInput.fill(NPM_PASS);
// Click sign in
const signInBtn = page.locator('button[type="submit"], button:has-text("Sign In"), button:has-text("Log In")').first();
await signInBtn.click();
// Wait for user to complete 2FA/security key in the browser
console.log('Waiting for 2FA/security key authentication...');
const result = await Promise.race([
page.waitForURL('**/dashboard**', { timeout: 300000 }).then(() => 'dashboard'),
page.waitForURL('**/login/cli/**', { timeout: 300000 }).then(() => 'cli-callback'),
page.waitForURL('**/~**', { timeout: 300000 }).then(() => 'profile'),
]).catch(() => 'timeout');
console.log('Login result:', result, 'URL:', page.url());
// After successful login, create an automation token via the NPM API
if (result !== 'timeout') {
const tokenResult = await page.evaluate(async () => {
const res = await fetch('/-/npm/v1/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: '<NPM_PASS>', readonly: false, cidr_whitelist: [] }),
});
return res.json();
});
if (tokenResult && tokenResult.token) {
const npmrcPath = join(homedir(), '.npmrc');
writeFileSync(npmrcPath, `//registry.npmjs.org/:_authToken=${tokenResult.token}\n`);
console.log('Token written to .npmrc!');
}
}
// Verify authentication
try {
const whoami = execSync('npm whoami 2>&1').toString().trim();
console.log('npm whoami:', whoami);
} catch (e) {
console.log('npm whoami failed — manual intervention needed');
}
await browser.close();
Key points:
channel: 'chrome' uses the user's installed Chrome (with security key support)headless: false — MUST be visible for WebAuthn/security key prompts~/.npmrcbash
npm whoami
Must return the correct username before proceeding.
bash
npm publish --access public
For scoped packages ( @scope/name), --access public is required on first publish.
If publish fails with E403:
npm view <package>@<version>npm access list packagesIf publish fails with E404:
npm org ls <scope-name> or check on npmjs.combash
npm view <package-name> version
Confirm the published version matches.
ErrorCauseFixE401 UnauthorizedNot logged inRun Step 4E403 ForbiddenVersion already published or no permissionBump version or check scope accessE404 Not FoundScope doesn't exist or no publish rightsCreate scope on npmjs.com or request accessWebAuthn not working in PlaywrightHeadless mode or wrong browserUse channel: 'chrome' + headless: falseToken expiredNPM tokens have limited lifetimeRe-run Step 4
npm-login.mjs script should be created as a temp file and deleted after usenpm-login.mjs to .gitignore