hash()

One async function over the four SHA digests Web Crypto exposes, with the output shape you ask for.

import { hash } from "unsecure/hash";

await hash("hello world");
// "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"

#Signature

function hash(
  data: string | BytesSource,
  options?: {
    algorithm?: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512";
    returnAs?: "hex" | "base64" | "b64" | "base64url" | "b64url" | "uint8array" | "bytes";
  },
): Promise<string | Uint8Array>;

#Options

OptionTypeDefaultWhat it does
algorithmDigestAlgorithm"SHA-256"Which digest to compute. Matched case-insensitively, so "sha-256" works.
returnAsDigestReturnAsmirrors the input typeThe shape of the result.

SHA-1 is there for interoperability with systems that still require it, such as the default OTP algorithm. Do not pick it for anything new.

#Return shape

You passedYou get back
a stringa hex string
a BytesSourcea Uint8Array
anything, plus returnAsexactly what returnAs names
await hash("hello world", { returnAs: "base64" });
// "uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="

await hash("hello world", { returnAs: "base64url" });
// "uU0nuZNNPgilLlLX2n2r-sSE7-N6U4DukIj3rOLvzek"

await hash("hello world", { algorithm: "SHA-512" });
// "309ecc489c12d6eb4cc40f50c902f2b4d0ed77ee511a7c7a9bcd3ca86d4cd86f989dd35bc5ff…"

await hash(new TextEncoder().encode("hello world"));
// Uint8Array(32) [185, 77, 39, 185, …]

await hash(new TextEncoder().encode("hello world"), { returnAs: "hex" });
// "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"

Strings are encoded as UTF-8 before hashing.

#Errors

Causecode
data is neither text nor bytesINVALID_TYPE
algorithm is not one of the fourUNSUPPORTED
returnAs is not a name the library knowsUNSUPPORTED
Web Crypto refused the digest after the checks passedPLATFORM

Both UNSUPPORTED checks run before Web Crypto is reached, so a typo costs nothing. See Errors.

#Recipe: store an API token without storing the token

You have to be able to check a token a client sends. You do not have to be able to read it back. Store the digest.

import { hash } from "unsecure/hash";
import { secureGenerate } from "unsecure/generate";
import { secureCompare } from "unsecure/compare";

// Issue: show `token` to the user once, keep only the digest
const token = secureGenerate({ length: 48, specials: false });
const tokenHash = await hash(token);
await db.tokens.insert({ userId, tokenHash });

// Check, on each request
async function findToken(received: string) {
  const receivedHash = await hash(received);
  const row = await db.tokens.findByHash(receivedHash);
  if (!row) return null;
  return secureCompare(row.tokenHash, receivedHash) ? row : null;
}

A plain SHA-256 is the right tool here and the wrong tool for a password, because a token has full random entropy and a password does not. See Password hashing for the other case.

#Recipe: notice that content changed

import { hash } from "unsecure/hash";

const etag = await hash(JSON.stringify(document), { returnAs: "base64url" });
if (etag === request.headers.get("if-none-match")) return new Response(null, { status: 304 });

#Pitfall: large files and streams

hash() calls crypto.subtle.digest, which needs the whole input in memory. Web Crypto has no incremental digest, so there is nothing the library can do about it.

// Loads the entire file into memory
const fileHash = await hash(hugeFileBuffer);

For streams, reach for the runtime's own API: crypto.createHash() in Node, crypto.subtle.digestStream() where it exists.

#Pitfall: comparing digests with ===

A digest is not secret, but the value you compare it against often is. Use secureCompare whenever the comparison decides whether a request is authorized.