From 222ab467d584dba40ce4c9231778e2ede5b13aaa Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Sat, 21 Feb 2026 01:38:03 +0000 Subject: [PATCH 1/7] feat(route-rules): add basic auth rule --- src/build/virtual/routing.ts | 2 +- src/runtime/internal/route-rules.ts | 9 +++++++-- src/types/route-rules.ts | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/build/virtual/routing.ts b/src/build/virtual/routing.ts index 4f51d4db83..5e0e68c9b0 100644 --- a/src/build/virtual/routing.ts +++ b/src/build/virtual/routing.ts @@ -1,6 +1,6 @@ import type { Nitro, NitroEventHandler, NitroRouteRules } from "nitro/types"; -export const RuntimeRouteRules = ["headers", "redirect", "proxy", "cache"] as string[]; +export const RuntimeRouteRules = ["headers", "redirect", "proxy", "cache", "auth"] as string[]; export default function routing(nitro: Nitro) { return { diff --git a/src/runtime/internal/route-rules.ts b/src/runtime/internal/route-rules.ts index 88214c5c17..a50832fae3 100644 --- a/src/runtime/internal/route-rules.ts +++ b/src/runtime/internal/route-rules.ts @@ -1,10 +1,10 @@ -import { proxyRequest, redirect as sendRedirect } from "h3"; +import { proxyRequest, redirect as sendRedirect, requireBasicAuth } from "h3"; import type { EventHandler, Middleware } from "h3"; import type { MatchedRouteRule, NitroRouteRules } from "nitro/types"; import { joinURL, withQuery, withoutBase } from "ufo"; import { defineCachedHandler } from "./cache.ts"; -// Note: Remember to update RuntimeRouteRules in src/routing.ts when adding new route rules +// Note: Remember to update RuntimeRouteRules in src/build/virtual/routing.ts when adding new route rules type RouteRuleCtor = (m: MatchedRouteRule) => Middleware; @@ -79,3 +79,8 @@ export const cache: RouteRuleCtor<"cache"> = ((m) => } return cachedHandler(event); }) satisfies RouteRuleCtor<"cache">; + + export const auth: RouteRuleCtor<"auth"> = (m) => + function authRouteRule(event) { + return requireBasicAuth(event, m.options); + }; diff --git a/src/types/route-rules.ts b/src/types/route-rules.ts index f6ec8e187d..a27766ca91 100644 --- a/src/types/route-rules.ts +++ b/src/types/route-rules.ts @@ -4,6 +4,26 @@ import type { CachedEventHandlerOptions } from "./runtime/index.ts"; export type HTTPstatus = IntRange<100, 600>; +export interface NitroBasicAuthOptions { + /** + * Username for basic auth validation. + */ + username?: string; + + /** + * Password for basic auth validation. + */ + password: string; + + /** + * Realm for the basic auth challenge. + * + * @default "" + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/WWW-Authenticate#realm + */ + realm?: string; +} + export interface NitroRouteConfig { cache?: ExcludeFunctions | false; headers?: Record; @@ -11,6 +31,7 @@ export interface NitroRouteConfig { prerender?: boolean; proxy?: string | ({ to: string } & ProxyOptions); isr?: number /* expiration */ | boolean | VercelISRConfig; + auth?: NitroBasicAuthOptions | false; // Shortcuts cors?: boolean; From c594edafbcb658d9f52aacbef6f782786bcf8e47 Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Sat, 21 Feb 2026 02:02:59 +0000 Subject: [PATCH 2/7] docs(route-rules): auth route rule --- docs/1.docs/5.routing.md | 49 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/1.docs/5.routing.md b/docs/1.docs/5.routing.md index 473112d1bc..880ee32f09 100644 --- a/docs/1.docs/5.routing.md +++ b/docs/1.docs/5.routing.md @@ -312,7 +312,7 @@ export default defineHandler((event) => { You can use the [utilities available in H3](https://h3.dev/guide/basics/error) to handle errors in both routes and middlewares. -The way errors are sent back to the client depends on the environment. In development, requests with an `Accept` header of `text/html` (such as browsers) will receive a HTML error page. In production, errors are always sent in JSON. +The way errors are sent back to the client depends on the environment. In development, requests with an `Accept` header of `text/html` (such as browsers) will receive a HTML error page. In production, errors are always sent in JSON. This behaviour can be overridden by some request properties (e.g.: `Accept` or `User-Agent` headers). @@ -324,7 +324,7 @@ See [`inlineDynamicImports`](/config#inlinedynamicimports) to bundle everything ## Route rules -Nitro allows you to add logic at the top-level for each route of your configuration. It can be used for redirecting, proxying, caching and adding headers to routes. +Nitro allows you to add logic at the top-level for each route of your configuration. It can be used for redirecting, proxying, caching, authentication, and adding headers to routes. It is a map from route pattern (following [rou3](https://github.com/h3js/rou3)) to route options. @@ -351,6 +351,51 @@ export default defineConfig({ '/old-page/**': { redirect: '/new-page/**' }, '/proxy/example': { proxy: 'https://example.com' }, '/proxy/**': { proxy: '/api/**' }, + '/admin/**': { auth: { password: 'supersecret' } }, + } +}); +``` + +### Basic authentication + +The `auth` route rule protects matching routes with [HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Authentication). When a request hits a protected route, the browser prompts the user for credentials before granting access. + +```ts [nitro.config.ts] +import { defineNitroConfig } from "nitro/config"; + +export default defineConfig({ + routeRules: { + '/admin/**': { + auth: { + username: 'admin', + password: 'supersecret', + } + } + } +}); +``` + +The `auth` option accepts the following properties: + +| Property | Type | Required | Description | +| ---------- | -------- | -------- | ------------------------------------------------------------------ | +| `password` | `string` | Yes | Password for basic auth validation. | +| `username` | `string` | No | Username for basic auth validation. If omitted, any username is accepted. | +| `realm` | `string` | No | Realm for the basic auth challenge. Defaults to `""`. | + +You can also explicitly disable authentication for a sub-route by setting `auth: false`: + +```ts [nitro.config.ts] +import { defineNitroConfig } from "nitro/config"; + +export default defineConfig({ + routeRules: { + '/admin/**': { + auth: { password: 'supersecret' } + }, + '/admin/public/**': { + auth: false, + } } }); ``` From a32579e0bdfa4d8f3aa698d508f80a832c14e25c Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Sat, 21 Feb 2026 02:09:29 +0000 Subject: [PATCH 3/7] test(route-rules): test basic auth --- test/fixture/nitro.config.ts | 3 +++ test/tests.ts | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index ba9b84b47c..cc63dcd3bb 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -106,6 +106,9 @@ export default defineConfig({ "/rules/_/cached/**": { swr: true }, "/api/proxy/**": { proxy: "/api/echo" }, "/cdn/**": { proxy: "https://cdn.jsdelivr.net/**" }, + "/rules/auth/**": { auth: { password: "testpass" } }, + "/rules/auth-user/**": { auth: { username: "admin", password: "secret" } }, + "/rules/no-auth/**": { auth: false }, "**": { headers: { "x-test": "test" } }, }, prerender: { diff --git a/test/tests.ts b/test/tests.ts index 684406edc3..7c6f2cce1d 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -386,6 +386,56 @@ export function testNitro( expect(headers).toMatchObject(expectedHeaders); }); + describe("handles route rules - basic auth", () => { + it("rejects request without credentials", async () => { + const { status } = await callHandler({ url: "/rules/auth/test" }); + expect(status).toBe(401); + }); + + it("rejects request with wrong password", async () => { + const { status } = await callHandler({ + url: "/rules/auth/test", + headers: { + Authorization: "Basic " + btoa("user:wrongpass"), + }, + }); + expect(status).toBe(401); + }); + + it("allows request with correct password", async () => { + const { status } = await callHandler({ + url: "/rules/auth/test", + headers: { + Authorization: "Basic " + btoa("user:testpass"), + }, + }); + expect(status).toBe(200); + }); + + it("validates username when configured", async () => { + const { status: wrongUser } = await callHandler({ + url: "/rules/auth-user/test", + headers: { + Authorization: "Basic " + btoa("wrong:secret"), + }, + }); + expect(wrongUser).toBe(401); + + const { status: correctUser } = await callHandler({ + url: "/rules/auth-user/test", + headers: { + Authorization: "Basic " + btoa("admin:secret"), + }, + }); + expect(correctUser).toBe(200); + }); + + it("skips auth when set to false", async () => { + const { status } = await callHandler({ url: "/rules/no-auth/test" }); + expect(status).toBe(200); + }); + }); + it("handles route rules - allowing overriding", async () => { const override = await callHandler({ url: "/rules/nested/override" }); expect(override.headers.location).toBe("/other"); From 23a85926be27fedd4c2e17d84efba7d8132ef09e Mon Sep 17 00:00:00 2001 From: Rihan Arfan Date: Sat, 21 Feb 2026 02:12:39 +0000 Subject: [PATCH 4/7] chore: consistency --- src/runtime/internal/route-rules.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/runtime/internal/route-rules.ts b/src/runtime/internal/route-rules.ts index a50832fae3..ae96df6784 100644 --- a/src/runtime/internal/route-rules.ts +++ b/src/runtime/internal/route-rules.ts @@ -80,7 +80,11 @@ export const cache: RouteRuleCtor<"cache"> = ((m) => return cachedHandler(event); }) satisfies RouteRuleCtor<"cache">; - export const auth: RouteRuleCtor<"auth"> = (m) => - function authRouteRule(event) { - return requireBasicAuth(event, m.options); - }; +// Auth route rule +export const auth: RouteRuleCtor<"auth"> = ((m) => + function authRouteRule(event) { + if (!m.options) { + return; + } + return requireBasicAuth(event, m.options); + }) satisfies RouteRuleCtor<"auth">; From 6d64618bc34f1c8ec64a2115ce21e53859de7301 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 23 Feb 2026 11:24:58 +0100 Subject: [PATCH 5/7] update --- src/runtime/internal/route-rules.ts | 7 ++++--- src/types/route-rules.ts | 24 ++---------------------- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/src/runtime/internal/route-rules.ts b/src/runtime/internal/route-rules.ts index ae96df6784..ff50570543 100644 --- a/src/runtime/internal/route-rules.ts +++ b/src/runtime/internal/route-rules.ts @@ -1,5 +1,5 @@ import { proxyRequest, redirect as sendRedirect, requireBasicAuth } from "h3"; -import type { EventHandler, Middleware } from "h3"; +import type { BasicAuthOptions, EventHandler, Middleware } from "h3"; import type { MatchedRouteRule, NitroRouteRules } from "nitro/types"; import { joinURL, withQuery, withoutBase } from "ufo"; import { defineCachedHandler } from "./cache.ts"; @@ -82,9 +82,10 @@ export const cache: RouteRuleCtor<"cache"> = ((m) => // Auth route rule export const auth: RouteRuleCtor<"auth"> = ((m) => - function authRouteRule(event) { + async function authRouteRule(event, next) { if (!m.options) { return; } - return requireBasicAuth(event, m.options); + await requireBasicAuth(event, m.options as BasicAuthOptions); + return next(); }) satisfies RouteRuleCtor<"auth">; diff --git a/src/types/route-rules.ts b/src/types/route-rules.ts index a27766ca91..aa8bce52da 100644 --- a/src/types/route-rules.ts +++ b/src/types/route-rules.ts @@ -1,29 +1,9 @@ -import type { Middleware, ProxyOptions } from "h3"; +import type { Middleware, ProxyOptions, BasicAuthOptions } from "h3"; import type { ExcludeFunctions, IntRange } from "./_utils.ts"; import type { CachedEventHandlerOptions } from "./runtime/index.ts"; export type HTTPstatus = IntRange<100, 600>; -export interface NitroBasicAuthOptions { - /** - * Username for basic auth validation. - */ - username?: string; - - /** - * Password for basic auth validation. - */ - password: string; - - /** - * Realm for the basic auth challenge. - * - * @default "" - * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/WWW-Authenticate#realm - */ - realm?: string; -} - export interface NitroRouteConfig { cache?: ExcludeFunctions | false; headers?: Record; @@ -31,7 +11,7 @@ export interface NitroRouteConfig { prerender?: boolean; proxy?: string | ({ to: string } & ProxyOptions); isr?: number /* expiration */ | boolean | VercelISRConfig; - auth?: NitroBasicAuthOptions | false; + auth?: Pick | false; // Shortcuts cors?: boolean; From b9be0c21a5fd7833f91b5e626a087551df95d4bb Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 23 Feb 2026 11:28:14 +0100 Subject: [PATCH 6/7] update --- docs/1.docs/5.routing.md | 45 +---------------------------- src/build/virtual/routing.ts | 2 +- src/runtime/internal/route-rules.ts | 4 +-- src/types/route-rules.ts | 2 +- test/fixture/nitro.config.ts | 6 ++-- 5 files changed, 8 insertions(+), 51 deletions(-) diff --git a/docs/1.docs/5.routing.md b/docs/1.docs/5.routing.md index 880ee32f09..e53ebb3558 100644 --- a/docs/1.docs/5.routing.md +++ b/docs/1.docs/5.routing.md @@ -351,51 +351,8 @@ export default defineConfig({ '/old-page/**': { redirect: '/new-page/**' }, '/proxy/example': { proxy: 'https://example.com' }, '/proxy/**': { proxy: '/api/**' }, - '/admin/**': { auth: { password: 'supersecret' } }, + '/admin/**': { basicAuth: { username: 'admin', password: 'supersecret' } }, } }); ``` -### Basic authentication - -The `auth` route rule protects matching routes with [HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Authentication). When a request hits a protected route, the browser prompts the user for credentials before granting access. - -```ts [nitro.config.ts] -import { defineNitroConfig } from "nitro/config"; - -export default defineConfig({ - routeRules: { - '/admin/**': { - auth: { - username: 'admin', - password: 'supersecret', - } - } - } -}); -``` - -The `auth` option accepts the following properties: - -| Property | Type | Required | Description | -| ---------- | -------- | -------- | ------------------------------------------------------------------ | -| `password` | `string` | Yes | Password for basic auth validation. | -| `username` | `string` | No | Username for basic auth validation. If omitted, any username is accepted. | -| `realm` | `string` | No | Realm for the basic auth challenge. Defaults to `""`. | - -You can also explicitly disable authentication for a sub-route by setting `auth: false`: - -```ts [nitro.config.ts] -import { defineNitroConfig } from "nitro/config"; - -export default defineConfig({ - routeRules: { - '/admin/**': { - auth: { password: 'supersecret' } - }, - '/admin/public/**': { - auth: false, - } - } -}); -``` diff --git a/src/build/virtual/routing.ts b/src/build/virtual/routing.ts index 5e0e68c9b0..6b0c6d1c6b 100644 --- a/src/build/virtual/routing.ts +++ b/src/build/virtual/routing.ts @@ -1,6 +1,6 @@ import type { Nitro, NitroEventHandler, NitroRouteRules } from "nitro/types"; -export const RuntimeRouteRules = ["headers", "redirect", "proxy", "cache", "auth"] as string[]; +export const RuntimeRouteRules = ["headers", "redirect", "proxy", "cache", "basicAuth"] as string[]; export default function routing(nitro: Nitro) { return { diff --git a/src/runtime/internal/route-rules.ts b/src/runtime/internal/route-rules.ts index ff50570543..1100bc50a6 100644 --- a/src/runtime/internal/route-rules.ts +++ b/src/runtime/internal/route-rules.ts @@ -80,8 +80,8 @@ export const cache: RouteRuleCtor<"cache"> = ((m) => return cachedHandler(event); }) satisfies RouteRuleCtor<"cache">; -// Auth route rule -export const auth: RouteRuleCtor<"auth"> = ((m) => +// basicAuth auth route rule +export const basicAuth: RouteRuleCtor<"auth"> = ((m) => async function authRouteRule(event, next) { if (!m.options) { return; diff --git a/src/types/route-rules.ts b/src/types/route-rules.ts index aa8bce52da..a158b5a3ac 100644 --- a/src/types/route-rules.ts +++ b/src/types/route-rules.ts @@ -11,7 +11,7 @@ export interface NitroRouteConfig { prerender?: boolean; proxy?: string | ({ to: string } & ProxyOptions); isr?: number /* expiration */ | boolean | VercelISRConfig; - auth?: Pick | false; + basicAuth?: Pick | false; // Shortcuts cors?: boolean; diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index cc63dcd3bb..f648c9dbee 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -106,9 +106,9 @@ export default defineConfig({ "/rules/_/cached/**": { swr: true }, "/api/proxy/**": { proxy: "/api/echo" }, "/cdn/**": { proxy: "https://cdn.jsdelivr.net/**" }, - "/rules/auth/**": { auth: { password: "testpass" } }, - "/rules/auth-user/**": { auth: { username: "admin", password: "secret" } }, - "/rules/no-auth/**": { auth: false }, + "/rules/auth/**": { basicAuth: { password: "testpass" } }, + "/rules/auth-user/**": { basicAuth: { username: "admin", password: "secret" } }, + "/rules/no-auth/**": { basicAuth: false }, "**": { headers: { "x-test": "test" } }, }, prerender: { From 8206228c9f6e4e9463ef02da5bbbe1e1508342bf Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Mon, 23 Feb 2026 11:38:09 +0100 Subject: [PATCH 7/7] update tests (failing for www-authenticate) --- test/fixture/nitro.config.ts | 7 ++++--- test/tests.ts | 38 ++++++++---------------------------- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/test/fixture/nitro.config.ts b/test/fixture/nitro.config.ts index f648c9dbee..53fdb7b36b 100644 --- a/test/fixture/nitro.config.ts +++ b/test/fixture/nitro.config.ts @@ -106,9 +106,10 @@ export default defineConfig({ "/rules/_/cached/**": { swr: true }, "/api/proxy/**": { proxy: "/api/echo" }, "/cdn/**": { proxy: "https://cdn.jsdelivr.net/**" }, - "/rules/auth/**": { basicAuth: { password: "testpass" } }, - "/rules/auth-user/**": { basicAuth: { username: "admin", password: "secret" } }, - "/rules/no-auth/**": { basicAuth: false }, + "/rules/basic-auth/**": { + basicAuth: { username: "admin", password: "secret", realm: "Secure Area" }, + }, + "/rules/basic-auth/no-auth/**": { basicAuth: false }, "**": { headers: { "x-test": "test" } }, }, prerender: { diff --git a/test/tests.ts b/test/tests.ts index 7c6f2cce1d..a32df9bac8 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -387,51 +387,29 @@ export function testNitro( }); describe("handles route rules - basic auth", () => { - it("rejects request without credentials", async () => { - const { status } = await callHandler({ url: "/rules/auth/test" }); - expect(status).toBe(401); - }); - - it("rejects request with wrong password", async () => { - const { status } = await callHandler({ - url: "/rules/auth/test", + it("rejects request with bad creds", async () => { + const { status, headers } = await callHandler({ + url: "/rules/basic-auth", headers: { Authorization: "Basic " + btoa("user:wrongpass"), }, }); expect(status).toBe(401); + expect(headers["www-authenticate"]).toBe('Basic realm="Secure Area"'); }); it("allows request with correct password", async () => { const { status } = await callHandler({ - url: "/rules/auth/test", - headers: { - Authorization: "Basic " + btoa("user:testpass"), - }, - }); - expect(status).toBe(200); - }); - - it("validates username when configured", async () => { - const { status: wrongUser } = await callHandler({ - url: "/rules/auth-user/test", - headers: { - Authorization: "Basic " + btoa("wrong:secret"), - }, - }); - expect(wrongUser).toBe(401); - - const { status: correctUser } = await callHandler({ - url: "/rules/auth-user/test", + url: "/rules/basic-auth/test", headers: { Authorization: "Basic " + btoa("admin:secret"), }, }); - expect(correctUser).toBe(200); + expect(status).toBe(200); }); - it("skips auth when set to false", async () => { - const { status } = await callHandler({ url: "/rules/no-auth/test" }); + it("disabled basic-auth for sub-rules", async () => { + const { status } = await callHandler({ url: "/rules/basic-auth/no-auth" }); expect(status).toBe(200); }); });