javascript
-
I Gave Install-Script Permission to a Package I Don't Have
There’s exactly one file in one of my wiki projects that’s a security control rather than a config file, and I hadn’t read it in months.
pnpm 11 removed
onlyBuiltDependenciesalong with four related settings and replaced all of them with a singleallowBuildsmap, described in the docs as “a map of package matchers to explicitly allow (true) or disallow (false) script execution.” A postinstall script is arbitrary code running on your machine at install time with your permissions, so the list of packages allowed to have one is the shortest and highest-consequence list in the repo.Mine has six entries. Two
true, fourfalse. Here’s thetruehalf:allowBuilds: sharp: true # native libvips bindings (image processing) "@xenova/transformers": true # native ML runtimeAnd here’s the count:
$ grep -c 'transformers' pnpm-lock.yaml 0 $ grep -rn 'xenova' --include='*.json' --include='*.yaml' --include='*.ts' . | grep -v node_modules pnpm-workspace.yaml:28: "@xenova/transformers": true # native ML runtimeZero references in the lockfile. One reference in the entire repository, and it’s the line granting the permission.
This project used to run embeddings locally. That approach is gone, search goes through a hosted retrieval service now, and the project’s own direction notes say not to reintroduce local embeddings. The dependency left. The standing permission to execute native build scripts on my machine stayed behind, waiting for a package that’s never going to be installed.
A dead grant isn’t a vulnerability, it’s a broken instrument
Nobody’s exploiting this. The package isn’t in the tree, so nothing runs. I want to be clear about that before anyone gets excited.
What it costs me is the ability to trust the file. An allowlist works as a control only if every entry means I read this package’s install script and I accept it. The moment one entry instead means this was true in a previous version of the project, the list becomes a record of the past, and the next person to open it has no way to tell the two kinds of entry apart without re-deriving all six from the lockfile. The next person is me, in four months, at which point I will absolutely assume the file is current.
This is the failure mode of every allowlist I’ve kept. They only grow. Removing an entry requires noticing that something left, and nothing tells you when a dependency stops existing.
The other
trueentry has the opposite problemsharpis granted a native build.sharpis also not in mypackage.json. It shows up anyway:$ pnpm why sharp [email protected] └─┬ [email protected] ├─┬ @astrojs/[email protected] │ └── <package> (dependencies) └── <package> (dependencies)Astro declares it as an optional dependency. I wrote about that mechanic in a different repo a few days ago, so I’ll skip the re-explanation. What’s new to me here is that the same workspace file also pins it:
overrides: vite: ^8.2.2 sharp: ^0.35.3The comment sitting above that block warns that several of these pins are CVE remediations for transitive dependencies, and not to relax a bound without checking the advisory it was added for. That’s a good note. It’s also attached to a package I never asked for, whose version I control only because I reached into the resolver and overrode somebody else’s optional dependency.
The
falseentries turned out to be the good news"@prisma/engines": false prisma: false better-sqlite3: false esbuild: falseThese four aren’t a new restriction, and it took me a minute to work out why they’re written down at all.
Under pnpm 10,
onlyBuiltDependencieswas an exclusive allowlist. Anything not on it got skipped. All four of these were already being skipped, silently, for as long as this project has existed. pnpm 11’sstrictDepBuildsrefuses to skip quietly, and exits non-zero when a dependency has an unreviewed build script.So the upgrade changed no behavior at all. It changed whether the behavior was visible, and converted four invisible skips into four decisions I had to write down and sign. That’s the good version of a breaking change, and it’s the reason the dead
@xenova/transformersentry was sitting right there for me to trip over.One more thing, since I had the shell open
$ grep -c '0.34.5' pnpm-lock.yaml 0 $ du -sh node_modules/.pnpm/@[email protected] 15M[email protected]has zero references in the current lockfile and is still on disk, next to 15M of libvips binaries compiled for it, which in turn sit next to the 17M of libvips 1.3.2 that the current resolution actually uses. The override moved from^0.34.4to^0.35.3in a dependency sweep last week, and the old native payload never left. That’spnpm store pruneterritory rather than a correctness bug, but it’s 15M of compiled image-processing code on a machine whose project doesn’t declare an image-processing dependency.What I’m changing
- Reconcile the allowlist against the lockfile in CI. Every key in
allowBuildsshould resolve to something inpnpm-lock.yaml, or the build should complain. It’s maybe ten lines of test and I don’t have it. - Say why, next to every entry. Two of my six have a reason comment. The reason is worth more than the package name, because the reason is the thing that expires.
- Re-read the file whenever an override moves. The pins and the build grants describe the same dependency graph, and they drifted apart without a word.
The security value of an allowlist lives in the reviewing, not in the file. I’ve been maintaining the file.
Sources
- pnpm build settings —
allowBuildsandstrictDepBuilds, plus the migration table from the five settings pnpm 11 removed - pnpm 11.0 release notes — the removal of
onlyBuiltDependenciesand friends - pnpm settings index — the full
pnpm-workspace.yamlsurface
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- Reconcile the allowlist against the lockfile in CI. Every key in
-
My MIME Allowlist Wasn't Enforcing Anything
I wrote a comment months ago that asserted a security property. Last week I read it back and didn’t believe myself.
The comment sat above the function in
src/lib/r2.tsthat hands out presigned upload URLs, and it said this:Only ContentType is signed, the browser replays it on the PUT.
That reads like a guarantee. The signature covers the content type, so a client can’t lie about what it’s uploading. Confident, specific, and load-bearing, because the whole upload design leans on it.
It was wrong. Not subtly wrong. The content type was never in the signature at all.
Why the uploads work this way
My site takes file uploads and puts them in Cloudflare R2. The bytes never touch my server. A client POSTs metadata to
/api/upload, gets back a presigned PUT URL, and sends the file straight to R2.There are good reasons for that shape. It saves bandwidth and memory on a small box, and my host’s edge proxy blocks multipart POSTs anyway, so routing file bytes through the app was never on the table.
The tradeoff is that the server stops being a gatekeeper. Once you hand someone a signed URL, the signature is the only thing standing between them and the bucket. So the server does its checking up front.
src/pages/api/upload.tsvalidates the request against a Zod schema, and the type field is an enum derived from an allowlist:const MIME_EXTENSIONS = { "image/png": ["png"], "image/jpeg": ["jpg", "jpeg"], // ... "application/pdf": ["pdf"], } as const;There’s a size cap at 10 MB, an extension cross-check so
evil.htmlcan’t ride in claiming to be a PNG, and the signed URL expires after 300 seconds. That’s a reasonable set of controls. I felt fine about it.The probe
The doubt was cheap to resolve, so I resolved it. Two presigned URLs, same command, one with the fix and one without, printing the one query parameter that matters:
const cmd = () => new PutObjectCommand({ Bucket: "b", Key: "uploads/x.png", ContentType: "image/png", }); const before = new URL(await getSignedUrl(client, cmd(), { expiresIn: 300 })); const after = new URL(await getSignedUrl(client, cmd(), { expiresIn: 300, signableHeaders: new Set(["content-type"]), }));Output:
BEFORE SignedHeaders: host AFTER SignedHeaders: content-type;hosthost. That’s the entire list. I passedContentTypeinto the command, the SDK accepted it without complaint, and it never made it intoX-Amz-SignedHeaders.SigV4 query signing only binds the headers named in
X-Amz-SignedHeaders. Everything else is free. A header that isn’t listed isn’t covered by the signature, so the server won’t reject a request that changes it. My URL committed to exactly one thing, the hostname, and left the rest open.So the allowlist was real, the validation ran, the enum rejected anything outside those types, and none of it reached storage. A URL signed for
image/pngwould accept an HTML file, a script, whatever you wanted, for five minutes. The check happened. It just wasn’t binding.The parameter that looks like the fix
While digging through this I hit the trap that probably explains how I got it wrong.
There’s a query parameter called
response-content-type. It shows up on presigned URLs, it has “content type” in the name, and it does nothing to protect an upload. It sets theContent-Typeheader S3 sends back when the object is read later. It’s response metadata. It doesn’t constrain the PUT.If you’re skimming a signed URL looking for evidence that content type is handled, that parameter is right there, looking like proof.
The fix
One option on the presigner call:
const uploadUrl = await getSignedUrl(client, command, { expiresIn: PRESIGN_EXPIRY_SECONDS, signableHeaders: new Set(["content-type"]), });Now the header is in the signature. If the browser PUTs a different
Content-Typethan the one the server signed, R2 rejects the request. The allowlist finally reaches the bucket.The comment got rewritten too, into something that describes a mechanism instead of promising an outcome:
Content-Type is part of the signature, the browser must replay the same header value on the PUT.
What I actually changed my mind about
The one line fix isn’t the interesting part. The interesting part is that a comment made a security claim, sat in the file for months, and nothing anywhere could tell me it had gone stale. Comments don’t run.
So the fix shipped with a contract test that does. It builds a real presigned URL with the actual SDK, no mocks, and asserts on the URL:
expect(signedHeaders).toContain("host"); expect(signedHeaders).toContain("content-type"); expect(url.searchParams.get("X-Amz-Expires")).toBe("300"); expect(url.searchParams.get("response-content-type")).toBeNull();That last line is worth reviewing. It’s a test that exists to say “don’t come back and mistake the lookalike for the real thing.” Presigning is a pure local operation, no network call to R2, so the whole thing runs in about 300ms in CI.
Four things worth stealing from this if you hand out presigned upload URLs:
- Print
X-Amz-SignedHeaderson a URL you’re issuing right now. If it sayshost, your content type isn’t enforced, no matter what you passed to the command. - Passing
ContentTypetoPutObjectCommandis not enforcement. You needsignableHeaders: new Set(["content-type"])on the presigner. response-content-typeis a read-time hint. It’s not an upload control.- Server-side validation that never reaches storage is decoration. Mine was correct and thorough and completely bypassable.
The uncomfortable version of this: I built the allowlist, wrote the enum, added the extension cross-check, capped the size, set a short expiry, and then documented a property the library never gave me. Every individual piece was right. The thing connecting them to the bucket wasn’t there.
Go print your signed headers.
Sources
- AWS s3-request-presigner README — documents
signableHeadersfor enforcing non-x-amz-*headers on presigned requests - Authenticating Requests: Using Query Parameters (SigV4) — how
X-Amz-SignedHeadersdefines what the signature covers - Cloudflare R2 presigned URLs — R2 generates presigned URLs server-side with no network call, and can restrict Content-Type
- Cloudflare R2 S3 API compatibility — which S3 behaviors R2 actually implements
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- Print
-
I Removed Three Dependencies. One of Them Actually Left.
In forty-six minutes one morning I opened three pull requests, and every one of them deleted a dependency.
The commits are timestamped, so I can be precise about it:
- 07:33 replace Sharp transforms with
Bun.Image - 07:55 enforce signed upload content type (the AWS SDK removal that didn’t happen)
- 08:19 adopt
Bun.YAMLfor sync
Two merged with the dependency gone from
package.json. One I abandoned on purpose. That’s a decent morning, and it’s how I described it to myself at the time.Then I went back and looked at the lockfile.
Only one of those packages actually left.
Sharp Is Still Sitting in node_modules Right Now
The image one looked like the clean win. My media pipeline generates thumbnail, medium, and large variants, plus WebP versions, and it used Sharp to do it. Bun 1.4 ships a native
Bun.Image, so I rewrotesrc/lib/media.tsonto it and pulled"sharp": "^0.35.3"out of my dependencies.The benchmark was encouraging. Same workload, 2400×1600 inputs, separate processes,
/usr/bin/time -lon macOS:Input Sharp peak RSS Bun.Image peak RSS Elapsed JPEG 218 MB 75 MB 224ms → 207ms PNG 185 MB 80 MB 389ms → 549ms WebP 177 MB 77 MB 238ms → 180ms Memory is not close. Roughly a third of the footprint across all three formats. Speed is a wash, and PNG got noticeably worse, about 40% slower. The encoded output isn’t byte-identical either: JPEG came out 9% larger, PNG 2% smaller, WebP 3% larger. Fine for my purposes, but it’s a re-encode, not a port.
One caveat I want to be honest about, because it undercuts the comparison: Sharp wouldn’t load under Bun 1.4.0 on this machine at all. It failed on a missing
libvips-cpp.8.18.3.dylib. Undernode -e 'import("sharp")'it imported fine. So that table is Sharp-under-Node against Bun.Image-under-Bun, which means it’s measuring two runtimes, not two image libraries.Anyway. I removed Sharp, the tests passed, I merged it.
Sharp is still installed. Here it is:
$ grep -c '"@img/sharp-\|"sharp"' bun.lock 27 $ ls -d node_modules/sharp node_modules/sharpAstro 7 declares Sharp as an optional dependency for its own image integration. My lockfile says so in as many words:
"[email protected]" ... "optionalDependencies": { "sharp": "0.35.2" }So when I deleted my direct dependency, the resolver didn’t drop Sharp. It just stopped hearing my opinion about which version to use, and fell back to Astro’s. Sharp went from 0.35.3 to 0.35.2. My dependency removal was, in lockfile terms, a downgrade.
The One That Actually Left
The YAML change is the boring one and it’s the only real removal.
My blog sync script parsed frontmatter with the
yamlpackage. Bun 1.4 hasBun.YAMLwithparseandstringify, which is the entire surface I was using:const data = Bun.YAML.parse(frontmatter);That’s it.
"yaml": "^2.9.0"came out ofpackage.json, one entry came out ofbun.lock, and nothing else in the tree wanted it. Gone.No benchmark for this one. I didn’t run one, because a dependency that does one thing I can do with a builtin doesn’t need a performance argument to justify deleting it.
The Third One I Talked Myself Out Of
The AWS SDK is the one I wrote about separately. Short version: Bun’s native
S3Clientpresigns URLs about 32x faster and would take 25 packages out of the lockfile, and I kept the AWS SDK anyway, because Bun 1.4 can’t sendCache-Controlon writes and can’t forceContent-Typeinto a presigned URL’s signed headers. Faster, smaller, and not behaviorally equivalent.That one at least failed honestly. I evaluated it, wrote down why not, and the packages are still there because I decided they should be.
Three Words That Mean Three Different Things
Here’s what I actually learned, and it’s a vocabulary problem more than a technical one. “I removed a dependency” turns out to describe at least three unrelated states:
- It’s out of my
package.json. Sharp, yaml. This is the one people mean when they say it, and it’s the weakest of the three. - It’s out of the lockfile and off the disk. Only yaml. Sharp is still there, 27 entries, physically present in
node_modules. - It’s out of
trustedDependencies. This is the one I undersold at the time.esbuildis now the only package in my project allowed to run install lifecycle scripts. Sharp used to be in that list and isn’t anymore.
Number three is the one that actually changed my exposure. Sharp is still on disk, but nothing in my code calls it, and it no longer gets to execute arbitrary code at install time. Number two never happened. Number one is mostly bookkeeping.
I would have told you I removed two dependencies that morning. The lockfile says I removed one, downgraded another, and revoked install-script trust from a package that’s still sitting right there. All three of those are fine outcomes. They’re just not the same outcome, and I had been filing them under one word.
Check your lockfile after you delete something. Transitive optional dependencies do not care what your
package.jsonsays.Sources
- Bun.Image runtime documentation — the image API that replaced Sharp
- Bun.YAML runtime documentation —
parseandstringify, nothing else - Bun 1.4 release announcement — released 2026-08-20
- Astro images guide — Astro’s built-in image service, which is why Sharp stays
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- 07:33 replace Sharp transforms with
-
Bun Was Faster. I Still Kept the AWS SDK.
I ran a benchmark that should have ended the argument.
One of my sites runs on Bun 1.4 and stores files in Cloudflare R2, which exposes an S3-compatible API. The application still used the AWS SDK for object reads, writes, metadata checks, deletes, and presigned upload URLs.
Bun has a native
S3Client. Replacing the AWS packages looked like exactly the kind of cleanup I wanted: fewer dependencies, less memory, and less code between the application and the runtime.Then I measured it.
For 10,000 presigned URLs, Bun’s native client finished in about 46 milliseconds. The AWS SDK took about 1,464 milliseconds.
After loading the client, the Bun process used roughly 12.3 MB of RSS compared with 40.9 MB for the AWS path. After the full run, it was about 19.5 MB versus 97 MB.
Removing the AWS client and presigner would also remove 25 AWS and Smithy packages from the lockfile.
Faster. Smaller. Fewer dependencies. Easy decision, right?
I kept the AWS SDK.
The Benchmark Was Real, but Incomplete
These numbers came from a single local run on macOS using Bun 1.4. They aren’t a statistically rigorous performance study, and presigning is offline CPU work. No object storage request happens while the URL is generated.
Still, the difference was large enough to matter. Bun’s native implementation was doing far less work.
The problem was not performance. The problem was behavioral parity.
An S3 client in this application does more than produce a valid signature. It has to preserve the exact metadata and security rules that the rest of the upload pipeline expects.
Two details stopped the migration.
Cache-Control Disappeared on Writes
The application writes two kinds of public objects:
- Generated image variants with a one-year cache lifetime
- Versioned static assets with a one-year, immutable cache policy
Those are stored as object metadata through the
Cache-Controlheader. Cloudflare R2 supports that metadata through its S3-compatible API.Bun 1.4’s S3 client exposes a fixed set of metadata options: content type, content encoding, and content disposition. It doesn’t take arbitrary request headers, and it doesn’t take
Cache-Control.I wanted to verify the behavior rather than infer it from the type definitions, so I pointed the client at a local HTTP capture server and inspected the outgoing PUT request.
This correctly emitted
Content-Type:await client.write("image.png", bytes, { type: "image/png", });Adding a
cacheControlproperty did nothing. Passing aResponsewith aCache-Controlheader did nothing. In both cases, the outgoing request omitted the header.The upload would work, but the stored object wouldn’t have the cache policy the application depends on. That’s not a drop-in replacement.
The Presigned URL Signed the Wrong Thing
The second blocker was more subtle.
The browser uploads files directly to object storage using a short-lived presigned PUT URL. If the server approves an
image/pngupload, the signature should require the browser to sendContent-Type: image/png.Bun’s presigner accepts a
typeoption:client.presign("uploads/image.png", { method: "PUT", expiresIn: 300, type: "image/png", });That looks right until you inspect the URL.
The generated query contained
response-content-type=image/png, whileX-Amz-SignedHeaderscontained onlyhost. The type affected response metadata. It didn’t require the upload request to contain a matchingContent-Typeheader.Bun 1.4 doesn’t expose an equivalent to the AWS presigner’s custom
signableHeadersoption. So I could generate a working PUT URL, but I couldn’t express the security contract I needed.Again, it worked. It just didn’t do the same job.
The Evaluation Found a Bug in the Existing Code
This part was worth the whole exercise.
The existing AWS code created a PUT command with
ContentType: "image/png", and the surrounding comments said the header was signed. The tests checked thatContentTypewas present on the command.They never inspected the final URL.
The AWS JavaScript presigner does not sign
Content-Typeby default. Its own documentation says to opt in withsignableHeaders:const uploadUrl = await getSignedUrl(client, command, { expiresIn: 300, signableHeaders: new Set(["content-type"]), });Once I generated a real URL with dummy credentials, the gap was obvious. Before the fix,
X-Amz-SignedHeaderscontained onlyhost. After the fix, it contained bothhostandcontent-type.I replaced the mocked-only assertion with an offline contract test that generates the actual URL and checks the signed headers and five-minute expiration.
So the failed migration still improved the application. It corrected a security assumption that the old test suite had been politely agreeing with.
What Would Make Me Reconsider?
This is a no for Bun 1.4, not a no forever.
I will revisit the native client when all of these are true:
- S3 writes can send arbitrary object metadata, including
Cache-Control. - Presigned PUT URLs can require request headers such as
Content-Type. - I can run the full flow against an isolated R2 test prefix: PUT, HEAD, read, list, delete, CORS, content type, and cache metadata.
The performance upside is sitting there waiting. A 32x difference in this micro-benchmark and dozens fewer dependency packages are both compelling.
But performance comes after correctness. A faster client that silently changes cache behavior or weakens an upload constraint isn’t an optimization. It’s a regression with good benchmark numbers.
I wanted the native implementation to win. This time, the boring dependency stayed.
Sources
- Bun S3 runtime documentation
- AWS SDK for JavaScript v3 S3 presigner documentation
- Cloudflare R2 presigned URL documentation
- Cloudflare R2 S3 API compatibility
- Cloudflare R2 metadata header mapping
- Bun issue #16048: allow for custom S3 headers/query params — open since December 2024
- Bun issue #18016: missing ResponseCacheControl options in s3.presign() — open since March 2025
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
pnpm 11 Made the Safe Thing the Default
Protecting against supply chain attacks requires vigilance. You have to audit your dependencies. You have to pin your versions. You have to review your install scripts. All of these are great things to do, but they require sustained effort.
pnpm 11 took the obvious thing and made it the default. They changed the waiting period.
minimumReleaseAgedefines the minimum number of minutes that must pass after a version is published before pnpm will install it.Before version 11 the default was 0. In version 11 the default is 1440 minutes, which is 24 hours, and it applies to everything.
Most malicious packages get discovered and pulled from the registry within minutes, or at most an hour. So what a day of patience buys you is that you’ve eliminated the potential for dependencies sneaking in that haven’t been fully vetted.
npm already followed suit, and so did everyone else. This is now table stakes across the ecosystem:
- pnpm got there first with
minimumReleaseAge, measured in minutes, back in 10.16 in September 2025. - Yarn shipped
npmMinimalAgeGate, also minutes, in 4.10.0 that same month. - Bun added
minimumReleaseAgein 1.3 in October 2025, measured in seconds, plus aminimumReleaseAgeExcludeslist for packages you trust. - npm landed
min-release-agein 11.10.0 in February 2026, measured in days.
I guess they couldn’t agree on the unit of time for their release age setting.
As far as I know, in all of them besides pnpm, the cooldown is opt-in. It’s not the default. So you have to know the setting exists and you have to go turn it on.
Here are some other things that changed in pnpm 11:
allowBuildsreplacesonlyBuiltDependencies, which was removed in v11. It’s a map of which packages may run build scripts. Anything not listed is disallowed and treated as unreviewed.strictDepBuildsdefaults totrue. Installation exits with a non-zero code if any dependency has unreviewed build scripts, so this fails your CI rather than printing a warning nobody reads.verifyDepsBeforeRundefaults toinstall. Beforepnpm runorpnpm exec, it checks whether your dependency state matches the lockfile. Other options arewarn,error,prompt, andfalse.dangerouslyAllowAllBuildsdefaults tofalse, and the name is doing exactly the work it should. Setting it true lets every dependency, transitive ones included, run install scripts now and in the future.
The clear pattern here is that an automated or unintentional action should be blocked, not permitted with a warning.
Sure, there is somewhat of a cost here. The delay means you can’t immediately install a new version that was just published unless you flip the flag. I can see the
allowBuildsmigration being somewhat of a hassle, because the first time you install after upgrading you’re going to get a list of packages that want to run build scripts. It’s easy to be lazy and approve all of them without thinking.With the tools we have available to us these days, we can ask an agent to review the build scripts. This is the right thing to do. Find a way to pin the dependency to fix transitive version issues. The inner engineer in all of us needs to understand why build scripts are dangerous, and what to be careful of, so that you can ask your subagent to go and see if that’s a problem, or if that problem has been fixed with the new version. It’s up to the human in the loop to ensure that the agents are doing their due diligence.
When building software, we should also look for other paths to optimize, and make the lazy path the safest one, because chances are that’s going to become the default.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- pnpm got there first with
-
Strapi 5 Docs | Strapi 5 Documentation
Get set up in minutes to build any project in hours instead of weeks.
-
npmx - Package Browser for the npm Registry
a fast, modern browser for the npm registry. Search, browse, and explore packages with a modern interface.
-
SvelteKit vs Astro: How I Choose in 2026
If you’ve followed my blog over the past few months, you know I’ve spent a lot of time digging into Svelte 5. Runes completely changed the way I think about reactivity, and SvelteKit is still one of the most elegant frameworks I’ve used for building genuinely interactive web apps.
So lately I’ve been thinking about: when does it make sense to use Astro instead of SvelteKit?
The honest answer is that they’re not really competing, or at least they didn’t used to be. Both frameworks are excellent, and historically they solved two pretty different problems. That line has gotten blurrier lately, but the old split is still the best starting point I’ve got. After building so many things with Astro’s recent updates, here’s how I think about picking one in 2026.
SvelteKit Is for Apps
When you’re building a dashboard, a complex SaaS tool, or anything where state needs to persist across a lot of dynamic route changes, SvelteKit is hard to beat.
SvelteKit assumes your app is going to do a lot of client-side work, and it leans into that. Form actions, nested layouts, complex data loading, it makes all of it straightforward. Combine the routing with Svelte 5’s runes and you get a developer experience that feels like writing plain JavaScript, except the reactivity just happens.
My rule of thumb: if the user is going to be clicking around, dragging things, and holding a long-lived session, I reach for SvelteKit.
Astro Is for Content
Astro comes at the web from the opposite direction. The core philosophy is “zero JS by default,” and it means it.
If you’re building a blog, a marketing site, documentation, or a storefront where the whole point is getting content in front of the reader as fast as possible, Astro wins. Its Islands Architecture ships raw, fast HTML and only hydrates the specific interactive pieces that actually need JavaScript. The result is a site that’s quick out of the box without you having to fight for it.
I should be fair, though: Astro hasn’t stayed in its lane. Recent releases have pushed it toward more dynamic, app-like territory with Server Islands, type-safe Actions, and a sessions API. The “content only” box doesn’t hold the way it did a year ago, and you can build genuinely interactive things in Astro now if you want to.
So I’d put it this way instead: as a rule of thumb, if it feels more like a website than a piece of software, that’s still where Astro shines.
You Don’t Actually Have to Choose
Here’s the the fun part, Astro has first-class support for Svelte.
You don’t give up Svelte to use Astro. Say I’m building a content-heavy site but I need one genuinely complex interactive piece, like a calendar component. I can write that calendar in Svelte, drop it into an Astro page, and tell Astro to hydrate only that component with a
client:loaddirective.You get the performance of a static or server-rendered Astro page, plus the developer experience of writing Svelte for the bits that need to be interactive. That’s a really good deal.
So Which One?
There’s real overlap now, so treat this as a default rather than a law. But it mostly still comes down to one question: are you building software or a website?
- Reach for SvelteKit when you’re building an application. If it feels like software, this is your tool.
- Reach for Astro when you’re building a content site. If SEO, initial load speed, and reading experience are the priorities, let Astro do the heavy lifting and sprinkle in Svelte components where you need them.
Pick the framework that matches the kind of thing you’re building, lean on the overlap when it helps, and don’t agonize over it. Either way you’re starting from a good place.
What are you reaching for first when you spin up a new project these days?
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
Sources
- Islands Architecture — Astro Docs — the “zero JS by default” philosophy and hydration control via client directives like
client:load. - Erika & Matthew Phillips, “Astro 4.12: Server Islands” (Astro Blog, July 18, 2024) — introduction of Server Islands to combine static caching and dynamic server-rendered blocks.
- Actions — Astro Docs — official documentation for Astro’s type-safe Actions API.
- Sessions — Astro Docs — official documentation for Astro’s sessions API.
- Introducing runes — Svelte Blog — Svelte 5 runes announcement introducing compiler-directive reactivity.
Astro javascript svelte Web development Sveltekit Frameworks
-
Declutter your JavaScript & TypeScript projects
Project linter to find unused dependencies, exports and files
Programming Tools Dev links code javascript software engineering
-
Your Software Is Mostly Strangers' Code
Modern applications aren’t really written anymore. They’re assembled. Seventy to ninety percent of a typical proprietary codebase is open-source code pulled from public registries, NPM, PyPI, crates.io, maintained by thousands of people you’ve never met. Every
npm installis an act of implicit trust extended to strangers, and that trust model has quietly become the weakest link in most security architectures.Attackers figured this out a long time ago. Compromising one popular package gives you a blast radius that phishing campaigns can only dream about: CI pipelines, developer laptops, production workloads, client devices, all simultaneously. SolarWinds. The XZ Utils backdoor. The Shai-Hulud worm, which self-propagated through 170+ npm and PyPI packages by hijacking GitHub Actions OIDC tokens and quietly minted new publish credentials as it spread. The ByBit developer compromise. These aren’t outliers anymore. They’re the shape of the threat.
I want to dig into the mechanics of how this actually happens, and then look at the ecosystem most developers touch every day: NPM.
How Supply Chain Attacks Actually Work
The first thing to understand is that supply chain attacks aren’t really “vulnerabilities” in the classic sense. A buffer overflow is an accidental weakness. A malicious package is intentional code, written to steal credentials, drop a reverse shell, or exfiltrate environment variables the moment it lands on your machine. Traditional appsec tools were built to find the former. They are largely blind to the latter.
The attack patterns cluster into a few categories.
Typosquatting. Publish
axoisand wait for someone to fat-fingeraxios. Sounds trivial, but it works constantly because developers install packages at high velocity and rarely double-check spelling.Dependency confusion. If your company has an internal package called
corp-auth, an attacker publishes a public package with the same name and a higher version number. Many package managers default to “highest version wins,” and your build pulls the public one instead of your internal one.Maintainer hijacking. Compromise a real maintainer through phishing, credential stuffing, or a missing 2FA setup, and push a poisoned update to a package that already has millions of weekly downloads. The Axios compromise in March 2026 followed exactly this pattern. The XZ Utils backdoor was a slower variant. The attacker spent months building trust as a “helpful” co-maintainer before slipping a backdoor into the build.
The thing that makes all of this so effective is the automation downstream. Unpinned versions, auto-merging update bots, transitive dependencies five layers deep. Once a malicious version hits the registry, it propagates fast.
Why NPM Is the Highest-Stakes Ecosystem
NPM serves tens of billions of downloads a week. A typical JavaScript project today pulls in well over a thousand transitive dependencies. Ten years ago that number was in the dozens. The dependency graph is just structurally enormous, and it’s getting worse.
The specific architectural problem in NPM is the lifecycle script, specifically
postinstall. NPM lets package authors define scripts inpackage.jsonthat run automatically when the package is installed. This was designed for legitimate reasons: compiling native bindings, configuring environments. But it also means arbitrary shell commands execute on your machine the moment you typenpm install. No code review. No second thought. Just immediate execution as your user.There are a few practical mitigations, and they’re worth knowing whether you’re a solo developer or running platform security at a large org.
Disable lifecycle scripts. Either pass
--ignore-scriptsad hoc, or set it globally:npm config set ignore-scripts trueThis breaks some legitimate packages (esbuild, bcrypt, anything compiling native code). To manage that, tools like
can-i-ignore-scriptsscan yournode_modulesand generate an allowlist of packages that genuinely need scripts to run. Frameworks like@lavamoat/allow-scriptsformalize this with a deterministic config you can check into the repo.Use
npm ciin CI, notnpm install. This is non-negotiable for production builds.npm installwill happily resolve newer minor versions inside your semver ranges and rewritepackage-lock.json.npm cirefuses to do that. If the lockfile doesn’t match exactly, the install fails. That’s the behavior you want when the question is “did anything change that I didn’t approve.”Consider switching to pnpm. pnpm 10+ has been quietly building some of the best structural defenses in the ecosystem. Postinstall scripts are off by default and require an explicit
allowBuildslist.blockExoticSubdepsprevents transitive deps from resolving via random Git URLs or tarballs.The killer feature, though, is
minimumReleaseAge. As of pnpm v11 (May 2026), the default is 1440 minutes, so pnpm simply refuses to resolve any package version less than 24 hours old. Most malicious packages get pulled from the registry within hours of being detected. A 24-hour cooldown turns the community into your early warning system, with no behavioral analysis or commercial tooling needed.That last one is the single highest-leverage change you can make as an individual developer. It costs nothing, it doesn’t break your workflow, and it neutralizes most day-zero registry malware before it ever reaches you.
Next post I’ll dig into the Python and Rust sides of this. pip’s
setup.pyexecution problem, Rust’sbuild.rsissue, and the surprisingly mature auditing toolchain the Rust community has built aroundcargo-audit,cargo-deny, andcargo-vet.Sources
- Securing Package Managers: Why NPM, PyPI, and Cargo Are High-Value Targets
- Defending Against NPM Supply Chain Attacks: A Practical Guide
- NPM Ignore Scripts Best Practices
- Mitigating supply chain attacks
- Get safe and remain productive with can-i-ignore-scripts
- The Landscape of Malicious Open Source Packages: 2025 Mid-Year Threat Report
- The Evolving Software Supply Chain Attack Surface
- Introducing OpenSSF’s Malicious Packages Repository
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].