Skip to content

Instantly share code, notes, and snippets.

@webstrand
Created September 9, 2025 18:55
Show Gist options
  • Save webstrand/14970758c78bcd6f5eab4d61102dac54 to your computer and use it in GitHub Desktop.
Save webstrand/14970758c78bcd6f5eab4d61102dac54 to your computer and use it in GitHub Desktop.
Plugin for adding basic auth to vite
import { type Plugin } from "vite";
import { default as parseBasicAuth, type BasicAuthResult } from "basic-auth";
import { createHash, timingSafeEqual, randomBytes } from "node:crypto";
import { type IncomingMessage } from "node:http";
const HASH_FUNCTION = "sha256";
const HASH_SIZE = createHash(HASH_FUNCTION).digest().length;
const SALT_SIZE = 16;
export type HashedPassword = readonly [hash: Buffer, salt: Buffer];
/**
* Map of usernames to {@link HashedPassword}s. Use {@link hashPassword} for the values
*/
export type Credentials = ReadonlyMap<string, HashedPassword | null | undefined>;
/**
* Function that returns {@link Credentials} for a given request or a control signal
* @param req The incoming HTTP request, it is unsafe to parse `Authorization`
* @returns A {@link Credentials} map to authenticate access, `true` to allow access,
* or `false | null | undefined` to deny access
*/
export type CredentialsFunction = (req: IncomingMessage) => Credentials | boolean | null | undefined;
/**
* Hash a password for inclusion in {@link Credentials}
* @param str The plaintext password to hash
* @returns `[hash, salt]` structure
*/
export function hashPassword(str: string): HashedPassword {
const salt = randomBytes(SALT_SIZE);
return Object.freeze([internalHashPassword(str, salt), salt]);
}
function internalHashPassword(str: string, salt: Buffer): Buffer {
return createHash(HASH_FUNCTION).update(str).update(salt).digest();
}
/**
* Vite plugin that adds HTTP Basic Authentication to the dev server.
* @param config Configuration object
* @returns Vite plugin instance
*/
export function basicAuth({
credentials,
realm,
}: {
/** Static credentials map or function that returns credentials per request */
credentials: Credentials | CredentialsFunction;
/** Realm name shown in browser auth dialog. If null/undefined, no challenge header is sent */
realm: string | null | undefined;
}): Plugin {
if (realm != null) {
if (/[\x00-\x1F\x7F]/.test(realm)) throw new Error("Invalid characters in realm name");
realm = realm.replace(/[\\"]/g, "\\$&");
}
// Placeholder to compare against in case username does not match
// this helps to prevent timing attacks exfiltrating usernames lists.
const dummyHashedPassword: HashedPassword = Object.freeze([randomBytes(HASH_SIZE), randomBytes(SALT_SIZE)]);
function verifyPassword(valid: Credentials, request: BasicAuthResult) {
const hashedPassword = valid.get(request.name) ?? dummyHashedPassword;
const hash = internalHashPassword(request.pass, hashedPassword[1]);
return timingSafeEqual(hash, hashedPassword[0]);
}
return {
name: "basic-auth",
configureServer(server) {
server.middlewares.use(function (req, res, next) {
const valid = typeof credentials === "function" ? credentials(req) : credentials;
if (valid === true) return next();
const reqCred = parseBasicAuth(req);
if (valid && reqCred && verifyPassword(valid, reqCred)) return next();
res.statusCode = 401;
if (valid && realm != null) res.setHeader("WWW-Authenticate", `Basic realm="${realm}"`);
res.end(valid && reqCred ? "Authentication Failure" : undefined);
return;
});
},
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment