TL;DR
On March 31, 2026, the axios npm package was backdoored by North Korean threat actor UNC1069 — versions 1.14.1 and 0.30.4 delivered a cross-platform Remote Access Trojan to any project that installed them. The safe axios versions are 1.14.0 and 0.30.3. If you're evaluating whether to stay on axios or migrate, here's the current state of Node.js HTTP clients: got for feature-complete Node.js API work, ky for browser/isomorphic apps, undici for maximum throughput, and native fetch if you need zero dependencies.
Key Takeaways
- Compromised versions: axios@1.14.1 (latest tag) and axios@0.30.4 (legacy tag) — both contain WAVESHAPER.V2 backdoor
- Safe versions: axios@1.14.0 and axios@0.30.3 — roll back if you pulled latest between March 31 00:21–03:25 UTC
- got: 26M weekly downloads, feature-rich, Node.js-only, ESM-first, built-in TypeScript, retries and hooks
- ky: 2M weekly downloads, 9.12 kB bundle, no dependencies, browser + Node.js, modern Fetch wrapper
- undici: 26M weekly downloads, 18,340 req/sec raw throughput, powers Node.js built-in
fetch, HTTP/2 - native fetch: zero install, built into Node.js 18+, good enough for most straightforward use cases
The March 2026 axios Supply Chain Attack
At 00:21 UTC on March 31, 2026, an attacker who had compromised the npm credentials of axios lead maintainer jasonsaayman published two backdoored releases simultaneously:
- axios@1.14.1 — pushed as the
latesttag, affecting anyone runningnpm install axiosor automated dependency updates - axios@0.30.4 — pushed as the
legacytag, targeting projects on the older 0.x branch
The malicious packages introduced a hidden dependency called plain-crypto-js, which contained an obfuscated postinstall script. The script executed immediately on installation — before any application code ran — and deployed WAVESHAPER.V2, a cross-platform backdoor attributed to UNC1069, a financially motivated North Korean threat actor active since at least 2018.
npm removed the compromised packages at 03:25 UTC, three hours after they appeared. Elastic Security Labs, Datadog Security Labs, and Google's GTIG published detailed analyses confirming the attribution and payload behavior. The WAVESHAPER.V2 backdoor exfiltrates cloud access keys, database passwords, and API tokens, and installs a persistent Remote Access Trojan on Windows, macOS, and Linux.
What this means for your project:
- If your lockfile pins
axios@1.14.1oraxios@0.30.4, treat any system that rannpm installbetween March 31 00:21 and 03:25 UTC as fully compromised. - If your lockfile pins
axios@1.14.0or earlier, you were not affected by this specific incident. - Projects using got, ky, undici, or native fetch were not impacted.
The attack has practical security implications beyond this single incident: axios's architecture requires a postinstall hook for browser compatibility shims, making it structurally dependent on a feature that supply chain attacks repeatedly exploit. Alternative libraries — ky, got, undici — carry no postinstall hooks at all.
Quick Comparison
| axios | got | ky | undici | native fetch | |
|---|---|---|---|---|---|
| Weekly Downloads | 100M | 26M | 2M | 26M | built-in |
| GitHub Stars | 108K | 14.7K | 13.8K | 7.1K | — |
| Bundle Size (min+gzip) | ~13 kB | ~48 kB | 3.23 kB | varies | 0 |
| Browser Support | ✅ | ❌ | ✅ | ❌ | ✅ |
| TypeScript | bundled | bundled | bundled | bundled | native |
| postinstall hooks | ✅ | ❌ | ❌ | ❌ | ❌ |
| HTTP/2 | ❌ | ✅ | ❌ | ✅ | partial |
| Built-in Retries | ❌ | ✅ | ✅ | ❌ | ❌ |
| License | MIT | MIT | MIT | MIT | — |
got: The Feature-Complete Node.js Client
got is the go-to axios alternative when you need a rich feature set and are writing Node.js-only code. Version 14.x is ESM-first with full TypeScript definitions bundled — no @types/got required.
npm install got
import got from 'got';
// Basic GET with automatic JSON parsing
const data = await got('https://api.example.com/users').json();
// POST with JSON body — got handles Content-Type automatically
const result = await got.post('https://api.example.com/users', {
json: { name: 'Alice', email: 'alice@example.com' },
}).json();
// Retry on failure (default: 2 retries with exponential backoff)
const withRetry = await got('https://api.example.com/data', {
retry: {
limit: 3,
methods: ['GET'],
statusCodes: [429, 500, 502, 503],
},
}).json();
// Timeout configuration
const withTimeout = await got('https://api.example.com/slow', {
timeout: {
request: 5000, // 5s total
connect: 1000, // 1s to establish connection
response: 3000, // 3s for first byte
},
}).json();
got's killer feature is its hooks system, which replaces axios interceptors with a more composable pattern:
import got, { type Got } from 'got';
// Create a reusable instance (equivalent to axios.create())
const api: Got = got.extend({
prefixUrl: 'https://api.example.com',
headers: { 'Authorization': `Bearer ${process.env.API_TOKEN}` },
hooks: {
beforeRequest: [
(options) => {
console.log(`→ ${options.method} ${options.url}`);
},
],
afterResponse: [
(response) => {
console.log(`← ${response.statusCode} ${response.url}`);
return response;
},
],
beforeError: [
(error) => {
console.error(`Request failed: ${error.message}`);
return error;
},
],
},
});
// Use the instance — prefixUrl + relative path
const users = await api.get('users').json();
const user = await api.post('users', { json: { name: 'Bob' } }).json();
got also supports streaming natively, which is where it pulls ahead of axios for server-side use:
import got from 'got';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
// Stream a large response to disk without buffering the whole response
await pipeline(
got.stream('https://example.com/large-file.zip'),
createWriteStream('./output.zip'),
);
When to use got: Node.js-only services, complex APIs needing retries and hooks, streaming downloads/uploads, HTTP/2 endpoints, projects where you want a direct axios migration path.
ky: The Modern Fetch Wrapper
ky wraps the native Fetch API with a developer-friendly interface. At 9.12 kB minified (3.23 kB gzip) with zero dependencies, it's the lightest full-featured option and runs identically in browsers, Node.js 18+, Deno, and Bun.
npm install ky
import ky from 'ky';
// GET with JSON — no .data wrapper, just the parsed object
const user = await ky.get('https://api.example.com/users/1').json();
// POST with automatic Content-Type: application/json
const created = await ky.post('https://api.example.com/users', {
json: { name: 'Alice' },
}).json();
// Built-in retry with exponential backoff
const resilient = await ky.get('https://api.example.com/flaky', {
retry: {
limit: 3,
methods: ['get', 'head'],
statusCodes: [429, 503],
backoffLimit: 3000,
},
}).json();
// Timeout (throws on expiry)
const timed = await ky.get('https://api.example.com/slow', {
timeout: 5000,
}).json();
ky's beforeRequest/afterResponse hooks work similarly to got's but are designed around the Fetch API's Request/Response objects:
import ky from 'ky';
const api = ky.create({
prefixUrl: 'https://api.example.com',
hooks: {
beforeRequest: [
(request) => {
request.headers.set('Authorization', `Bearer ${process.env.API_TOKEN}`);
},
],
afterResponse: [
async (_request, _options, response) => {
if (response.status === 401) {
// Refresh token and retry
const newToken = await refreshToken();
const retryRequest = new Request(response.url, {
headers: { Authorization: `Bearer ${newToken}` },
});
return ky(retryRequest);
}
return response;
},
],
},
});
Because ky is built on fetch, it works in Service Workers and Edge Runtimes (Cloudflare Workers, Vercel Edge Functions, Deno Deploy) where Node.js-specific APIs like http.request are unavailable. This is ky's primary advantage over got.
When to use ky: Browser-side code, Next.js apps that share fetch logic between client and server, edge runtimes, Deno/Bun projects, anywhere bundle size matters.
For a deeper ky vs. axios breakdown, see Axios vs ky in 2026.
undici: Maximum Throughput
undici is the HTTP/1.1 (and now HTTP/2) client that powers Node.js's built-in fetch. If you import fetch in Node.js 18+, you're already using undici under the hood. Installing it as a direct dependency gives you access to lower-level APIs and newer undici versions than what ships with your Node.js install.
npm install undici
undici benchmarks consistently show the highest raw throughput of any Node.js HTTP client:
50 TCP connections, pipelining depth 10, Node 22.x:
undici request: 18,340 req/sec
undici stream: 18,245 req/sec
undici pipeline: 13,364 req/sec
undici fetch: 5,903 req/sec ← same as Node.js built-in fetch
got: ~5,000 req/sec
axios: ~4,800 req/sec
The request and stream APIs provide this throughput by bypassing the fetch Response abstraction:
import { request } from 'undici';
// High-throughput request — no fetch overhead
const { statusCode, headers, body } = await request(
'https://api.example.com/data',
{ method: 'GET' }
);
const data = await body.json();
// Connection pooling (default behavior — reuses TCP connections)
import { Pool } from 'undici';
const pool = new Pool('https://api.example.com', {
connections: 20,
pipelining: 4,
});
// Batch requests using the pool
const responses = await Promise.all([
pool.request({ path: '/users', method: 'GET' }),
pool.request({ path: '/products', method: 'GET' }),
pool.request({ path: '/orders', method: 'GET' }),
]);
undici v6 (shipping with Node.js 22+) adds HTTP/2 multiplexing, which reduces latency significantly for API endpoints that accept concurrent requests over the same connection:
import { Agent } from 'undici';
// Force HTTP/2 connections
const agent = new Agent({ allowH2: true });
const { body } = await request('https://api.example.com/data', {
dispatcher: agent,
});
When to use undici: High-throughput servers making many outbound requests, microservices with strict latency requirements, cases where you need HTTP/2 connection pooling, proxy implementations.
For a focused undici vs got comparison, see got vs undici vs node-fetch (2026).
Native fetch: When Zero Dependencies Win
Node.js 18 shipped with fetch as a global — no install required. For simple API calls, it handles the most common cases without adding any package to your dependency tree:
// No imports needed in Node.js 18+
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const users = await response.json();
// POST with JSON
const created = await fetch('https://api.example.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice' }),
});
The supply chain attack against axios highlighted what many developers had already observed: adding a third-party HTTP client means adding a trusted target for future attacks. For simple CRUD API consumption, native fetch eliminates that surface.
The tradeoffs are real: no built-in retries, no request hooks, no automatic JSON parsing of the request body, no timeout via setTimeout integration (use AbortController with signal), and verbose error handling. For anything beyond simple request/response, reach for ky or got.
When to use native fetch: Lambda functions and serverless environments where cold start matters, internal CLI tools, scripts where dependencies are a burden, projects that only need basic GET/POST without retry or timeout semantics.
Migration: From axios to Alternatives
axios → got (Node.js services)
// Before: axios
import axios from 'axios';
const client = axios.create({
baseURL: 'https://api.example.com',
headers: { Authorization: `Bearer ${token}` },
timeout: 5000,
});
const { data } = await client.get('/users');
const { data: created } = await client.post('/users', { name: 'Alice' });
// After: got
import got from 'got';
const client = got.extend({
prefixUrl: 'https://api.example.com',
headers: { Authorization: `Bearer ${token}` },
timeout: { request: 5000 },
});
const data = await client.get('users').json();
const created = await client.post('users', { json: { name: 'Alice' } }).json();
axios → ky (browser/isomorphic)
// Before: axios
import axios from 'axios';
const { data } = await axios.get('https://api.example.com/users');
const { data: created } = await axios.post(
'https://api.example.com/users',
{ name: 'Alice' }
);
// After: ky
import ky from 'ky';
const data = await ky.get('https://api.example.com/users').json();
const created = await ky.post('https://api.example.com/users', {
json: { name: 'Alice' },
}).json();
The key migration difference: axios wraps responses in a { data } envelope. Both got and ky call .json() directly on the response — there is no response.data wrapper.
Interceptors → Hooks
// Before: axios interceptors
client.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) logout();
return Promise.reject(error);
}
);
// After: got hooks (same pattern, different API)
const client = got.extend({
hooks: {
beforeRequest: [(options) => {
options.headers['Authorization'] = `Bearer ${getToken()}`;
}],
afterResponse: [(response) => {
if (response.statusCode === 401) logout();
return response;
}],
},
});
When to Use Which
Still on axios?
→ Check your lockfile. axios@1.14.1 or @0.30.4? Rotate credentials, treat system as compromised.
→ Safe (1.14.0 or earlier)? axios remains viable — the maintainers responded quickly and added MFA.
New project / migration?
→ Need browser support or edge runtime? → ky
→ Node.js only, complex needs (retries, hooks, streaming, HTTP/2)? → got
→ High-throughput server, raw performance, connection pooling? → undici
→ Simple scripts, serverless, minimal dependencies? → native fetch
Browser-only React app?
→ ky or native fetch — axios's browser support is no longer a differentiator now that fetch is universal
The security argument for migration is real but shouldn't be overstated. The compromise window was three hours; projects that pin lockfile versions and use npm audit were protected. The deeper issue is structural: axios's postinstall hook gives supply chain attackers an automatic code execution primitive at install time. got, ky, and undici don't have that hook at all.
When Axios Still Wins
The security incident is real and the advice to migrate is sound — but it would be dishonest to pretend axios has no genuine advantages. For teams that are not migrating immediately, understanding what axios does well clarifies what they would lose.
Axios interceptors are the most mature request/response middleware system in the JavaScript HTTP client ecosystem. The pattern of adding an interceptor to attach a bearer token, then another to refresh that token on 401 responses and retry the original request, has been implemented and debugged in production across thousands of codebases. got's hooks system covers the same use case but with a different API, and ky's afterResponse hooks require more careful implementation to avoid infinite retry loops on repeated 401s. The axios interceptor pattern is battle-tested and has extensive Stack Overflow documentation.
Request and response transformation — converting dates from ISO strings to Date objects on every response, normalizing API-specific error shapes, adding HMAC signatures to every outbound request body — is cleanly handled by axios transformers. The transformRequest and transformResponse arrays let you build a processing pipeline that applies globally across a client instance. This reduces boilerplate in individual API call sites. In got and ky, this same behavior requires hooks, which achieve the same result but with more explicit wiring.
The axios plugin ecosystem, while smaller than it once was, includes useful packages like axios-retry (automatic retry with exponential backoff), axios-cache-adapter (response caching with configurable TTLs), and axios-mock-adapter (test mocking). These packages are mature and well-tested. got has some equivalent functionality built-in (retries), and ky has retries built-in as well, but the adapter-based mocking ecosystem for testing is richer in axios.
The baseURL + instance model in axios is clean for API clients. axios.create({ baseURL: 'https://api.example.com', timeout: 5000 }) produces an instance where every call automatically inherits those settings. The equivalent in got (got.extend({ prefixUrl })) and ky (ky.create({ prefixUrl })) works the same way, but axios's model has been documented and discussed more extensively, which matters for teams onboarding new developers.
The Native Fetch Maturity Story
Node.js 18 shipped fetch as an experimental global, Node.js 21 made it stable, and Node.js 22 ships with undici v6 as the underlying implementation. The maturity trajectory has been fast. For the most common HTTP use cases — JSON GET/POST requests, setting headers, reading response bodies — native fetch works correctly and predictably in 2026.
What remains missing compared to axios or got: no automatic request body serialization. With axios and got, passing json: { name: 'Alice' } automatically sets Content-Type: application/json and serializes the body. With native fetch, you write JSON.stringify(body) and set the header manually. This is two lines instead of one option, but it is a friction point that ky and got eliminate.
Timeout handling with native fetch requires AbortController with AbortSignal.timeout(). Node.js 17.3+ ships this API, so for Node.js 20+ you write fetch(url, { signal: AbortSignal.timeout(5000) }). This is now ergonomic enough that it is not a meaningful disadvantage over axios's timeout option.
The edge runtime story is where native fetch has definitively won. Cloudflare Workers, Vercel Edge Functions, and Deno Deploy all implement the Fetch API specification. Code written with native fetch or ky (which wraps fetch) runs identically on these platforms. got and axios, which depend on Node.js's http module, do not. For teams building edge-deployed Next.js middleware or Cloudflare Workers, the choice of ky or native fetch is not optional — it is required by the runtime.
The missing piece for native fetch in production backend services is retry logic. Transient network failures, 503s from overloaded downstream services, and rate limit 429s all require retry with backoff. Implementing this on top of native fetch is straightforward but verbose. For backend services that make many outbound requests to third-party APIs, wrapping fetch with a simple retry function or reaching for ky (which has retry built-in) is the pragmatic choice.
View download trends for axios vs got vs undici on PkgPulse.