diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1c05366..e7d222c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,24 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## Unreleased
+
+- Added a builtin API for hashing and calculating HMACs as part of the `serde` library
+
+  Basic usage:
+
+  ```lua
+  local serde = require("@lune/serde")
+  local hash = serde.hash("sha256", "a message to hash")
+  local hmac = serde.hmac("sha256", "a message to hash", "a secret string")
+
+  print(hash)
+  print(hmac)
+  ```
+
+  The returned hashes are sequences of lowercase hexadecimal digits. The following algorithms are supported:
+  `md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `sha3-224`, `sha3-256`, `sha3-384`, `sha3-512`, `blake3`
+
 ## `0.8.5` - June 1st, 2024
 
 ### Changed
diff --git a/Cargo.lock b/Cargo.lock
index e21c206c..13ae8cd1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -273,6 +273,7 @@ dependencies = [
  "cc",
  "cfg-if",
  "constant_time_eq 0.3.0",
+ "digest",
 ]
 
 [[package]]
@@ -1339,6 +1340,15 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "keccak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654"
+dependencies = [
+ "cpufeatures",
+]
+
 [[package]]
 name = "lazy_static"
 version = "1.4.0"
@@ -1582,13 +1592,20 @@ name = "lune-std-serde"
 version = "0.1.0"
 dependencies = [
  "async-compression",
+ "blake3",
  "bstr",
+ "digest",
+ "hmac",
  "lune-utils",
  "lz4",
+ "md-5",
  "mlua",
  "serde",
  "serde_json",
  "serde_yaml",
+ "sha1 0.10.6",
+ "sha2",
+ "sha3",
  "tokio",
  "toml",
 ]
@@ -1666,6 +1683,16 @@ dependencies = [
  "regex-automata 0.1.10",
 ]
 
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
 [[package]]
 name = "memchr"
 version = "2.7.2"
@@ -2665,6 +2692,27 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
 
+[[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha3"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
+dependencies = [
+ "digest",
+ "keccak",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
diff --git a/crates/lune-std-serde/Cargo.toml b/crates/lune-std-serde/Cargo.toml
index 91786ffd..ab7bec0f 100644
--- a/crates/lune-std-serde/Cargo.toml
+++ b/crates/lune-std-serde/Cargo.toml
@@ -29,6 +29,16 @@ serde_json = { version = "1.0", features = ["preserve_order"] }
 serde_yaml = "0.9"
 toml = { version = "0.8", features = ["preserve_order"] }
 
+digest = "0.10.7"
+hmac = "0.12.1"
+md-5 = "0.10.6"
+sha1 = "0.10.6"
+sha2 = "0.10.8"
+sha3 = "0.10.8"
+# This feature MIGHT break due to the unstable nature of the digest crate.
+# Check before updating it.
+blake3 = { version = "1.5.0", features = ["traits-preview"] }
+
 tokio = { version = "1", default-features = false, features = [
     "rt",
     "io-util",
diff --git a/crates/lune-std-serde/src/hash.rs b/crates/lune-std-serde/src/hash.rs
new file mode 100644
index 00000000..cf0d3c6f
--- /dev/null
+++ b/crates/lune-std-serde/src/hash.rs
@@ -0,0 +1,234 @@
+use std::fmt::Write;
+
+use bstr::BString;
+use md5::Md5;
+use mlua::prelude::*;
+
+use blake3::Hasher as Blake3;
+use sha1::Sha1;
+use sha2::{Sha224, Sha256, Sha384, Sha512};
+use sha3::{Sha3_224, Sha3_256, Sha3_384, Sha3_512};
+
+pub struct HashOptions {
+    algorithm: HashAlgorithm,
+    message: BString,
+    secret: Option<BString>,
+    // seed: Option<BString>,
+}
+
+#[derive(Debug, Clone, Copy)]
+enum HashAlgorithm {
+    Md5,
+    Sha1,
+    // SHA-2 variants
+    Sha2_224,
+    Sha2_256,
+    Sha2_384,
+    Sha2_512,
+    // SHA-3 variants
+    Sha3_224,
+    Sha3_256,
+    Sha3_384,
+    Sha3_512,
+    // Blake3
+    Blake3,
+}
+
+impl HashAlgorithm {
+    pub fn list_all_as_string() -> String {
+        [
+            "md5", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3-224", "sha3-256",
+            "sha3-384", "sha3-512", "blake3",
+        ]
+        .join(", ")
+    }
+}
+
+impl HashOptions {
+    /**
+        Computes the hash for the `message` using whatever `algorithm` is
+        contained within this struct and returns it as a string of hex digits.
+    */
+    #[inline]
+    #[must_use = "hashing a message is useless without using the resulting hash"]
+    pub fn hash(self) -> String {
+        use digest::Digest;
+
+        let message = self.message;
+        let bytes = match self.algorithm {
+            HashAlgorithm::Md5 => Md5::digest(message).to_vec(),
+            HashAlgorithm::Sha1 => Sha1::digest(message).to_vec(),
+            HashAlgorithm::Sha2_224 => Sha224::digest(message).to_vec(),
+            HashAlgorithm::Sha2_256 => Sha256::digest(message).to_vec(),
+            HashAlgorithm::Sha2_384 => Sha384::digest(message).to_vec(),
+            HashAlgorithm::Sha2_512 => Sha512::digest(message).to_vec(),
+
+            HashAlgorithm::Sha3_224 => Sha3_224::digest(message).to_vec(),
+            HashAlgorithm::Sha3_256 => Sha3_256::digest(message).to_vec(),
+            HashAlgorithm::Sha3_384 => Sha3_384::digest(message).to_vec(),
+            HashAlgorithm::Sha3_512 => Sha3_512::digest(message).to_vec(),
+
+            HashAlgorithm::Blake3 => Blake3::digest(message).to_vec(),
+        };
+
+        // We don't want to return raw binary data generally, since that's not
+        // what most people want a hash for. So we have to make a hex string.
+        bytes
+            .iter()
+            .fold(String::with_capacity(bytes.len() * 2), |mut output, b| {
+                let _ = write!(output, "{b:02x}");
+                output
+            })
+    }
+
+    /**
+        Computes the HMAC for the `message` using whatever `algorithm` and
+        `secret` are contained within this struct. The computed value is
+        returned as a string of hex digits.
+
+        # Errors
+
+        If the `secret` is not provided or is otherwise invalid.
+    */
+    #[inline]
+    pub fn hmac(self) -> LuaResult<String> {
+        use hmac::{Hmac, Mac, SimpleHmac};
+
+        let secret = self
+            .secret
+            .ok_or_else(|| LuaError::FromLuaConversionError {
+                from: "nil",
+                to: "string or buffer",
+                message: Some("Argument #3 missing or nil".to_string()),
+            })?;
+
+        /*
+            These macros exist to remove what would ultimately be dozens of
+            repeating lines. Essentially, there's several step to processing
+            HMacs, which expands into the 3 lines you see below. However,
+            the Hmac struct is specialized towards eager block-based processes.
+            In order to support anything else, like blake3, there's a second
+            type named `SimpleHmac`. This results in duplicate macros like
+            there are below.
+        */
+        macro_rules! hmac {
+            ($Type:ty) => {{
+                let mut mac: Hmac<$Type> = Hmac::new_from_slice(&secret).into_lua_err()?;
+                mac.update(&self.message);
+                mac.finalize().into_bytes().to_vec()
+            }};
+        }
+        macro_rules! hmac_no_blocks {
+            ($Type:ty) => {{
+                let mut mac: SimpleHmac<$Type> =
+                    SimpleHmac::new_from_slice(&secret).into_lua_err()?;
+                mac.update(&self.message);
+                mac.finalize().into_bytes().to_vec()
+            }};
+        }
+
+        let bytes = match self.algorithm {
+            HashAlgorithm::Md5 => hmac!(Md5),
+            HashAlgorithm::Sha1 => hmac!(Sha1),
+
+            HashAlgorithm::Sha2_224 => hmac!(Sha224),
+            HashAlgorithm::Sha2_256 => hmac!(Sha256),
+            HashAlgorithm::Sha2_384 => hmac!(Sha384),
+            HashAlgorithm::Sha2_512 => hmac!(Sha512),
+
+            HashAlgorithm::Sha3_224 => hmac!(Sha3_224),
+            HashAlgorithm::Sha3_256 => hmac!(Sha3_256),
+            HashAlgorithm::Sha3_384 => hmac!(Sha3_384),
+            HashAlgorithm::Sha3_512 => hmac!(Sha3_512),
+
+            HashAlgorithm::Blake3 => hmac_no_blocks!(Blake3),
+        };
+        Ok(bytes
+            .iter()
+            .fold(String::with_capacity(bytes.len() * 2), |mut output, b| {
+                let _ = write!(output, "{b:02x}");
+                output
+            }))
+    }
+}
+
+impl<'lua> FromLua<'lua> for HashAlgorithm {
+    fn from_lua(value: LuaValue<'lua>, _lua: &'lua Lua) -> LuaResult<Self> {
+        if let LuaValue::String(str) = value {
+            /*
+                Casing tends to vary for algorithms, so rather than force
+                people to remember it we'll just accept any casing.
+            */
+            let str = str.to_str()?.to_ascii_lowercase();
+            match str.as_str() {
+                "md5" => Ok(Self::Md5),
+                "sha1" => Ok(Self::Sha1),
+
+                "sha224" => Ok(Self::Sha2_224),
+                "sha256" => Ok(Self::Sha2_256),
+                "sha384" => Ok(Self::Sha2_384),
+                "sha512" => Ok(Self::Sha2_512),
+
+                "sha3-224" => Ok(Self::Sha3_224),
+                "sha3-256" => Ok(Self::Sha3_256),
+                "sha3-384" => Ok(Self::Sha3_384),
+                "sha3-512" => Ok(Self::Sha3_512),
+
+                "blake3" => Ok(Self::Blake3),
+
+                _ => Err(LuaError::FromLuaConversionError {
+                    from: "string",
+                    to: "HashAlgorithm",
+                    message: Some(format!(
+                        "Invalid hashing algorithm '{str}', valid kinds are:\n{}",
+                        HashAlgorithm::list_all_as_string()
+                    )),
+                }),
+            }
+        } else {
+            Err(LuaError::FromLuaConversionError {
+                from: value.type_name(),
+                to: "HashAlgorithm",
+                message: None,
+            })
+        }
+    }
+}
+
+impl<'lua> FromLuaMulti<'lua> for HashOptions {
+    fn from_lua_multi(mut values: LuaMultiValue<'lua>, lua: &'lua Lua) -> LuaResult<Self> {
+        let algorithm = values
+            .pop_front()
+            .map(|value| HashAlgorithm::from_lua(value, lua))
+            .transpose()?
+            .ok_or_else(|| LuaError::FromLuaConversionError {
+                from: "nil",
+                to: "HashAlgorithm",
+                message: Some("Argument #1 missing or nil".to_string()),
+            })?;
+        let message = values
+            .pop_front()
+            .map(|value| BString::from_lua(value, lua))
+            .transpose()?
+            .ok_or_else(|| LuaError::FromLuaConversionError {
+                from: "nil",
+                to: "string or buffer",
+                message: Some("Argument #2 missing or nil".to_string()),
+            })?;
+        let secret = values
+            .pop_front()
+            .map(|value| BString::from_lua(value, lua))
+            .transpose()?;
+        // let seed = values
+        //     .pop_front()
+        //     .map(|value| BString::from_lua(value, lua))
+        //     .transpose()?;
+
+        Ok(HashOptions {
+            algorithm,
+            message,
+            secret,
+            // seed,
+        })
+    }
+}
diff --git a/crates/lune-std-serde/src/lib.rs b/crates/lune-std-serde/src/lib.rs
index 4514a75b..4a66adf9 100644
--- a/crates/lune-std-serde/src/lib.rs
+++ b/crates/lune-std-serde/src/lib.rs
@@ -7,9 +7,11 @@ use lune_utils::TableBuilder;
 
 mod compress_decompress;
 mod encode_decode;
+mod hash;
 
 pub use self::compress_decompress::{compress, decompress, CompressDecompressFormat};
 pub use self::encode_decode::{decode, encode, EncodeDecodeConfig, EncodeDecodeFormat};
+pub use self::hash::HashOptions;
 
 /**
     Creates the `serde` standard library module.
@@ -24,6 +26,8 @@ pub fn module(lua: &Lua) -> LuaResult<LuaTable> {
         .with_function("decode", serde_decode)?
         .with_async_function("compress", serde_compress)?
         .with_async_function("decompress", serde_decompress)?
+        .with_function("hash", hash_message)?
+        .with_function("hmac", hmac_message)?
         .build_readonly()
 }
 
@@ -55,3 +59,11 @@ async fn serde_decompress(
     let bytes = decompress(bs, format).await?;
     lua.create_string(bytes)
 }
+
+fn hash_message(lua: &Lua, options: HashOptions) -> LuaResult<LuaString> {
+    lua.create_string(options.hash())
+}
+
+fn hmac_message(lua: &Lua, options: HashOptions) -> LuaResult<LuaString> {
+    lua.create_string(options.hmac()?)
+}
diff --git a/crates/lune/src/tests.rs b/crates/lune/src/tests.rs
index de726ce3..0306b293 100644
--- a/crates/lune/src/tests.rs
+++ b/crates/lune/src/tests.rs
@@ -230,6 +230,8 @@ create_tests! {
     serde_json_encode: "serde/json/encode",
     serde_toml_decode: "serde/toml/decode",
     serde_toml_encode: "serde/toml/encode",
+    serde_hashing_hash: "serde/hashing/hash",
+    serde_hashing_hmac: "serde/hashing/hmac",
 }
 
 #[cfg(feature = "std-stdio")]
diff --git a/tests/serde/hashing/hash.luau b/tests/serde/hashing/hash.luau
new file mode 100644
index 00000000..c0d5a27c
--- /dev/null
+++ b/tests/serde/hashing/hash.luau
@@ -0,0 +1,48 @@
+local serde = require("@lune/serde")
+
+local TEST_INPUT =
+	"Luau is a fast, small, safe, gradually typed embeddable scripting language derived from Lua."
+
+local function test_case_hash(algorithm: serde.HashAlgorithm, expected: string)
+	assert(
+		serde.hash(algorithm, TEST_INPUT) == expected,
+		`hashing algorithm '{algorithm}' did not hash test string correctly`
+	)
+	assert(
+		serde.hash(algorithm, buffer.fromstring(TEST_INPUT)) == expected,
+		`hashing algorithm '{algorithm}' did not hash test buffer correctly`
+	)
+end
+
+test_case_hash("blake3", "eccfe3a6696b2a1861c64cc78663cff51301058e5dc22bb6249e7e1e0173d7fe")
+test_case_hash("md5", "2aed9e020b49d219dc383884c5bd7acd")
+test_case_hash("sha1", "9dce74190857f36e6d3f5e8eb7fe704a74060726")
+test_case_hash("sha224", "f7ccd8a5f2697df8470b66f03824e073075292a1fab40d3a2ddc2e83")
+test_case_hash("sha256", "f1d149bfd1ea38833ae6abf2a6fece1531532283820d719272e9cf3d9344efea")
+test_case_hash(
+	"sha384",
+	"f6da4b47846c6016a9b32f01b861e45195cf1fa6fc5c9dd2257f7dc1c14092f11001839ec1223c30ab7adb7370812863"
+)
+test_case_hash(
+	"sha512",
+	"49fd834fdf3d4eaf4d4aff289acfc24d649f81cee7a5a7940e5c86854e04816f0a97c53f2ca4908969a512ec5ad1dc466422e3928f5ce3da9913959315df807c"
+)
+test_case_hash("sha3-224", "56a4dd1ff1bd9baff7f8bbe380dbf2c75b073161693f94ebf91aeee5")
+test_case_hash("sha3-256", "ee01be10e0dc133cd702999e854b396f40b039d5ba6ddec9d04bf8623ba04dd7")
+test_case_hash(
+	"sha3-384",
+	"e992f31e638b47802f33a4327c0a951823e32491ddcef5af9ce18cff84475c98ced23928d47ef51a8a4299dfe2ece361"
+)
+test_case_hash(
+	"sha3-512",
+	"08bd02aca3052b7740de80b8e8b9969dc9059a4bfae197095430e0aa204fbd3afb11731b127559b90c2f7e295835ea844ddbb29baf2fdb1d823046052c120fc9"
+)
+
+local failed = pcall(serde.hash, "a random string" :: any, "input that shouldn't be hashed")
+assert(failed == false, "serde.hash shouldn't allow invalid algorithms passed to it!")
+
+assert(
+	serde.hash("sha256", "\0oh no invalid utf-8\127\0\255")
+		== "c18ed3188f9e93f9ecd3582d7398c45120b0b30a0e26243809206228ab711b78",
+	"serde.hash should hash invalid UTF-8 just fine"
+)
diff --git a/tests/serde/hashing/hmac.luau b/tests/serde/hashing/hmac.luau
new file mode 100644
index 00000000..0af7c230
--- /dev/null
+++ b/tests/serde/hashing/hmac.luau
@@ -0,0 +1,60 @@
+local serde = require("@lune/serde")
+
+local INPUT_STRING = "important data to verify the integrity of"
+
+-- if you read this string, you're obligated to keep it a secret! :-)
+local SECRET_STRING = "don't read this we operate on the honor system"
+
+local function test_case_hmac(algorithm: serde.HashAlgorithm, expected: string)
+	assert(
+		serde.hmac(algorithm, INPUT_STRING, SECRET_STRING) == expected,
+		`HMAC test for algorithm '{algorithm}' was not correct with string input and string secret`
+	)
+	assert(
+		serde.hmac(algorithm, INPUT_STRING, buffer.fromstring(SECRET_STRING)) == expected,
+		`HMAC test for algorithm '{algorithm}' was not correct with string input and buffer secret`
+	)
+	assert(
+		serde.hmac(algorithm, buffer.fromstring(INPUT_STRING), SECRET_STRING) == expected,
+		`HMAC test for algorithm '{algorithm}' was not correct with buffer input and string secret`
+	)
+	assert(
+		serde.hmac(algorithm, buffer.fromstring(INPUT_STRING), buffer.fromstring(SECRET_STRING))
+			== expected,
+		`HMAC test for algorithm '{algorithm}' was not correct with buffer input and buffer secret`
+	)
+end
+
+test_case_hmac("blake3", "1d9c1b9405567fc565c2c3c6d6c0e170be72a2623d29911f43cb2ce42a373c01")
+test_case_hmac("md5", "525379669c93ab5f59d2201024145b79")
+test_case_hmac("sha1", "75227c11ed65133788feab0ce7eb8efc8c1f0517")
+test_case_hmac("sha224", "47a4857d7d7e1070f47f76558323e03471a918facaf3667037519c29")
+test_case_hmac("sha256", "4a4816ab8d4b780a8cf131e34a3df25e4c7bc4eba453cd86e50271aab4e95f45")
+test_case_hmac(
+	"sha384",
+	"6b24aeae78d0f84ec8a4669b24bda1131205535233c344f4262c1f90f29af04c5537612c269bbab8aaca9d8293f4a280"
+)
+test_case_hmac(
+	"sha512",
+	"9fffa071241e2f361f8a47a97d251c1d4aae37498efbc49745bf9916d8431f1f361080d350067ed65744d3da42956da33ec57b04901a5fd63a891381a1485ef7"
+)
+test_case_hmac("sha3-224", "ea102dfaa74aa285555bdba29a04429dfd4e997fa40322459094929f")
+test_case_hmac("sha3-256", "17bde287e4692e5b7f281e444efefe92e00696a089570bd6814fd0e03d7763d2")
+test_case_hmac(
+	"sha3-384",
+	"24f68401653d25f36e7ee8635831215f8b46710d4e133c9d1e091e5972c69b0f1d0cb80f5507522fa174d5c4746963c1"
+)
+test_case_hmac(
+	"sha3-512",
+	"d2566d156c254ced0101159f97187dbf48d900b8361fa5ebdd7e81409856b1b6a21d93a1fb6e8f700e75620d244ab9e894454030da12d158e9362ffe090d2669"
+)
+
+local failed =
+	pcall(serde.hmac, "a random string" :: any, "input that shouldn't be hashed", "not a secret")
+assert(failed == false, "serde.hmac shouldn't allow invalid algorithms passed to it!")
+
+assert(
+	serde.hmac("sha256", "\0oh no invalid utf-8\127\0\255", SECRET_STRING)
+		== "1f0d7f65016e9e4c340e3ba23da2483a7dc101ce8a9405f834c23f2e19232c3d",
+	"serde.hmac should hash invalid UTF-8 just fine"
+)
diff --git a/types/serde.luau b/types/serde.luau
index c4a21d89..ff127141 100644
--- a/types/serde.luau
+++ b/types/serde.luau
@@ -2,6 +2,19 @@ export type EncodeDecodeFormat = "json" | "yaml" | "toml"
 
 export type CompressDecompressFormat = "brotli" | "gzip" | "lz4" | "zlib"
 
+export type HashAlgorithm =
+	"md5"
+	| "sha1"
+	| "sha224"
+	| "sha256"
+	| "sha384"
+	| "sha512"
+	| "sha3-224"
+	| "sha3-256"
+	| "sha3-384"
+	| "sha3-512"
+	| "blake3"
+
 --[=[
 	@class Serde
 
@@ -120,4 +133,16 @@ function serde.decompress(format: CompressDecompressFormat, s: buffer | string):
 	return nil :: any
 end
 
+function serde.hash(algorithm: HashAlgorithm, message: string | buffer): string
+	return nil :: any
+end
+
+function serde.hmac(
+	algorithm: HashAlgorithm,
+	message: string | buffer,
+	secret: string | buffer
+): string
+	return nil :: any
+end
+
 return serde