diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index afb722f3..97d65767 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -60,13 +60,25 @@ repos:
         language: system
         files: \.php$
         args: [-l]
-      - id: assert-no-die-exit
-        name: Assert no die()/exit()
-        entry: ./test/assert-no-die-exit.bash
+      - id: assert-UnityHTTPD-used
+        name: Assert UnityHTTPD functions are used
+        entry: bash ./test/assert-UnityHTTPD-used.bash
         language: system
         files: \.php$
         exclude: |
           (?x)^(
-              resources/lib/UnityHTTPD\.php$|
+              resources/lib/UnityHTTPD\.php|
               workers/.*|
           )$
+      - id: assert-utils-used
+        name: Assert utils are used
+        entry: bash ./test/assert-utils-used.bash
+        language: system
+        files: \.php$
+        exclude: ^resources/lib/utils\.php$
+      - id: assert-exceptions-used
+        name: Assert exceptions are used
+        entry: bash ./test/assert-exceptions-used.bash
+        language: system
+        files: ^resources/.*\.php$
+        exclude: ^resources/lib/UnityHTTPD\.php$
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ea7c19ec..6f437e3a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,8 +7,20 @@
 * The maximum line length for any PHP file is 100 characters, instead of PSR-12's 120 characters.
 * Comments should be used sparingly.
 * Empty lines should be used sparingly.
-* No code should call `die()` or `exit()`, instead `UnityHTTPD::die()`. This will avoid the premature death of our automated testing processes.
-* Instead of `assert`, use `\ensure`. This will enforce conditions even in production.
+* No code should fail quietly, instead exceptions should be thrown.
+  PHP builtin functions that fail quietly (ex: `json_encode`) should be replaced with a wrapper in `resources/utils.php`.
+* No code should call `die()` or `exit()`, instead `UnityHTTPD::die()`.
+  This will avoid the premature death of our automated testing processes.
+* No code should call `assert()`, instead `\ensure()`.
+  This will enforce conditions even in production.
+* No code should call `json_encode()`, instead `\jsonEncode()`.
+  This will throw errors and escape slashes by default.
+* No code should call `mb_convert_encoding()`, instead `\mbConvertEncoding()`.
+  This will throw an exception rather than returning `false`.
+* No code should call `mb_detect_encoding()`, instead `\mbDetectEncoding()`.
+  This will enable strict mode and throw an exception rather than returning `false`.
+* `UnityHTTPD`'s user-facing error functionality (ex: `badRequest`) should only be called from `webroot/**/*.php`.
+  `resources/**/*.php` should throw exceptions instead.
 
 This repository will automatically check PRs for linting compliance.
 
diff --git a/resources/autoload.php b/resources/autoload.php
index a4326b05..6ec01691 100644
--- a/resources/autoload.php
+++ b/resources/autoload.php
@@ -29,6 +29,8 @@
 require_once __DIR__ . "/lib/exceptions/SSOException.php";
 require_once __DIR__ . "/lib/exceptions/ArrayKeyException.php";
 require_once __DIR__ . "/lib/exceptions/EnsureException.php";
+require_once __DIR__ . "/lib/exceptions/EncodingUnknownException.php";
+require_once __DIR__ . "/lib/exceptions/EncodingConversionException.php";
 
 require_once __DIR__ . "/config.php";
 require __DIR__ . "/init.php";
diff --git a/resources/lib/UnityHTTPD.php b/resources/lib/UnityHTTPD.php
index 42e826e5..542ce41d 100644
--- a/resources/lib/UnityHTTPD.php
+++ b/resources/lib/UnityHTTPD.php
@@ -58,9 +58,14 @@ public static function errorLog(
             $output["trace"] = explode("\n", (new \Exception())->getTraceAsString());
         }
         if (!is_null($data)) {
-            $output["data"] = $data;
+            try {
+                \jsonEncode($data);
+                $output["data"] = $data;
+            } catch (\JsonException $e) {
+                $output["data"] = "data could not be JSON encoded: " . $e->getMessage();
+            }
         }
-        error_log("$title: " . json_encode($output, JSON_UNESCAPED_SLASHES));
+        error_log("$title: " . \jsonEncode($output));
     }
 
     // recursive on $t->getPrevious()
@@ -147,8 +152,11 @@ public static function getPostData(...$keys)
         }
     }
 
-    public static function getUploadedFileContents($filename, $do_delete_tmpfile_after_read = true)
-    {
+    public static function getUploadedFileContents(
+        $filename,
+        $do_delete_tmpfile_after_read = true,
+        $encoding = "UTF-8",
+    ) {
         try {
             $tmpfile_path = \arrayGet($_FILES, $filename, "tmp_name");
         } catch (ArrayKeyException $e) {
@@ -161,14 +169,15 @@ public static function getUploadedFileContents($filename, $do_delete_tmpfile_aft
         if ($do_delete_tmpfile_after_read) {
             unlink($tmpfile_path);
         }
-        return $contents;
+        $old_encoding = mbDetectEncoding($contents);
+        return mbConvertEncoding($contents, $encoding, $old_encoding);
     }
 
     // in firefox, the user can disable alert/confirm/prompt after the 2nd or 3rd popup
     // after I disable alerts, if I quit and reopen my browser, the alerts come back
     public static function alert(string $message)
     {
-        // json_encode escapes quotes
-        echo "";
+        // jsonEncode escapes quotes
+        echo "";
     }
 }
diff --git a/resources/lib/UnityLDAP.php b/resources/lib/UnityLDAP.php
index 7d1ae60d..74e30c08 100644
--- a/resources/lib/UnityLDAP.php
+++ b/resources/lib/UnityLDAP.php
@@ -332,7 +332,7 @@ public function getAllPIGroupOwnerAttributes($attributes)
         if (count($owners_not_found) > 0) {
             UnityHTTPD::errorLog(
                 "warning",
-                "PI group owners not found: " . json_encode($owners_not_found) . "\n"
+                "PI group owners not found: " . \jsonEncode($owners_not_found) . "\n"
             );
         }
         return $owner_attributes;
diff --git a/resources/lib/UnityWebhook.php b/resources/lib/UnityWebhook.php
index 3f479eb2..61c9dd1d 100644
--- a/resources/lib/UnityWebhook.php
+++ b/resources/lib/UnityWebhook.php
@@ -54,7 +54,7 @@ public function sendWebhook($template = null, $data = null)
         curl_setopt($ch, CURLOPT_POST, 1);
         curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json'));
         curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
-        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(array('text' => $message)));
+        curl_setopt($ch, CURLOPT_POSTFIELDS, \jsonEncode(array('text' => $message)));
         $result = curl_exec($ch);
         curl_close($ch);
         return $result;
diff --git a/resources/lib/exceptions/EncodingConversionException.php b/resources/lib/exceptions/EncodingConversionException.php
new file mode 100644
index 00000000..ac316d89
--- /dev/null
+++ b/resources/lib/exceptions/EncodingConversionException.php
@@ -0,0 +1,7 @@
+ [1][2]["foo"]
-                implode("", array_map(fn($x) => json_encode([$x]), $keysTraversed))
+                implode("", array_map(fn($x) => jsonEncode([$x]), $keysTraversed))
             );
         }
         $cursor = $cursor[$key];
@@ -53,3 +55,31 @@ function testValidSSHKey($key_str)
         return false;
     }
 }
+
+function jsonEncode($value, $flags = 0, $depth = 512)
+{
+    $flags |= JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES;
+    return json_encode($value, $flags, $depth);
+}
+
+function mbConvertEncoding($string, $to_encoding, $from_encoding = null)
+{
+    $output = mb_convert_encoding($string, $to_encoding, $from_encoding);
+    if ($output === false) {
+        throw new EncodingConversionException(
+            jsonEncode(
+                ["to" => $to_encoding, "from" => $from_encoding, "base64" => base64_encode($string)]
+            )
+        );
+    }
+    return $output;
+}
+
+function mbDetectEncoding($string, $encodings = null, $_ = null)
+{
+    $output = mb_detect_encoding($string, $encodings, strict: true);
+    if ($output === false) {
+        throw new EncodingUnknownException(base64_encode($string));
+    }
+    return $output;
+}
diff --git a/test/assert-UnityHTTPD-used.bash b/test/assert-UnityHTTPD-used.bash
new file mode 100644
index 00000000..a5b92dee
--- /dev/null
+++ b/test/assert-UnityHTTPD-used.bash
@@ -0,0 +1,22 @@
+set -euo pipefail
+trap 's=$?; echo "$0: Error on line "$LINENO": $BASH_COMMAND"; exit $s' ERR
+if [[ $# -lt 1 ]]; then
+    echo "at least one argument required"
+    exit 1
+fi
+
+funcs=(die exit)
+rc=0
+for func in "${funcs[@]}"; do
+    # --color=never because magit git output log doesn't support it
+    grep_rc=0; grep -H --color=never --line-number -P '\b'"$func"'\s*[\(;]' "$@" | grep -v -E 'UnityHTTPD::'"$func" || grep_rc=$?
+    case "$grep_rc" in
+        0)
+            echo "$func() are not allowed! use UnityHTTPD::die() instead."; rc=1 ;;
+        1)
+            : ;; # code is good, do nothing
+        *)
+            echo "grep failed!";  rc=1 ;;
+    esac
+done
+exit "$rc"
diff --git a/test/assert-exceptions-used.bash b/test/assert-exceptions-used.bash
new file mode 100644
index 00000000..90177c74
--- /dev/null
+++ b/test/assert-exceptions-used.bash
@@ -0,0 +1,22 @@
+set -euo pipefail
+trap 's=$?; echo "$0: Error on line "$LINENO": $BASH_COMMAND"; exit $s' ERR
+if [[ $# -lt 1 ]]; then
+    echo "at least one argument required"
+    exit 1
+fi
+
+funcs=(badRequest forbidden internalServerError)
+rc=0
+for func in "${funcs[@]}"; do
+    # --color=never because magit git output log doesn't support it
+    grep_rc=0; grep -H --color=never --line-number -P '\b'"$func"'\s*[\(;]' "$@" || grep_rc=$?
+    case "$grep_rc" in
+        0)
+            echo "$func() is not allowed! use an exception instead."; rc=1 ;;
+        1)
+            : ;; # code is good, do nothing
+        *)
+            echo "grep failed!";  rc=1 ;;
+    esac
+done
+exit "$rc"
diff --git a/test/assert-no-die-exit.bash b/test/assert-no-die-exit.bash
deleted file mode 100755
index 638c2e6b..00000000
--- a/test/assert-no-die-exit.bash
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-if [[ $# -lt 1 ]]; then
-    echo "at least one argument required"
-    exit 1
-fi
-
-rc=0
-
-# --color=never because magit git output log doesn't support it
-die_occurrences="$(
-    grep -H --color=never --line-number -P '\bdie\s*[\(;]' "$@" | grep -v -P 'UnityHTTPD::die'
-)" || true
-if [ -n "$die_occurrences" ]; then
-    echo "die is not allowed! use UnityHTTPD::die() instead."
-    echo "$die_occurrences"
-    rc=1
-fi
-
-# --color=never because magit git output log doesn't support it
-exit_occurrences="$(grep -H --color=never --line-number -P '\bexit\s*[\(;]' "$@")" || true
-if [ -n "$exit_occurrences" ]; then
-    echo "exit is not allowed! use UnityHTTPD::die() instead."
-    echo "$exit_occurrences"
-    rc=1
-fi
-
-exit "$rc"
diff --git a/test/assert-utils-used.bash b/test/assert-utils-used.bash
new file mode 100644
index 00000000..51e95e31
--- /dev/null
+++ b/test/assert-utils-used.bash
@@ -0,0 +1,29 @@
+set -euo pipefail
+trap 's=$?; echo "$0: Error on line "$LINENO": $BASH_COMMAND"; exit $s' ERR
+if [[ $# -lt 1 ]]; then
+    echo "at least one argument required"
+    exit 1
+fi
+
+declare -A utils=(
+    ["assert"]="ensure"
+    ["json_encode"]="jsonEncode"
+    ["mb_detect_encoding"]="mbDetectEncoding"
+    ["mb_convert_encoding"]="mbConvertEncoding"
+)
+
+rc=0
+for replaced in "${!utils[@]}"; do
+    replacement="${utils[$replaced]}"
+    # --color=never because magit git output log doesn't support it
+    grep_rc=0; grep -H --color=never --line-number -P '\b'"$replaced"'\s*[\(;]' "$@" || grep_rc=$?
+    case "$grep_rc" in
+        0)
+            echo "$replaced() are not allowed! use $replacement() instead."; rc=1 ;;
+        1)
+            : ;; # code is good, do nothing
+        *)
+            echo "grep failed!";  rc=1 ;;
+    esac
+done
+exit "$rc"
diff --git a/test/functional/NewUserTest.php b/test/functional/NewUserTest.php
index 268d5190..e9254ec1 100644
--- a/test/functional/NewUserTest.php
+++ b/test/functional/NewUserTest.php
@@ -108,14 +108,14 @@ private function ensureUserDoesNotExist()
             $org = $USER->getOrgGroup();
             if ($org->exists() and $org->inOrg($USER)) {
                 $org->removeUser($USER);
-                assert(!$org->inOrg($USER));
+                ensure(!$org->inOrg($USER));
             }
             $LDAP->getUserEntry($USER->uid)->delete();
-            assert(!$USER->exists());
+            ensure(!$USER->exists());
         }
         if ($USER->getGroupEntry()->exists()) {
             $USER->getGroupEntry()->delete();
-            assert(!$USER->getGroupEntry()->exists());
+            ensure(!$USER->getGroupEntry()->exists());
         }
         $all_users_group = $LDAP->getUserGroup();
         $all_member_uids = $all_users_group->getAttribute("memberuid");
@@ -127,7 +127,7 @@ private function ensureUserDoesNotExist()
                 array_values(array_diff($all_member_uids, [$USER->uid]))
             );
             $all_users_group->write();
-            assert(!in_array($USER->uid, $all_users_group->getAttribute("memberuid")));
+            ensure(!in_array($USER->uid, $all_users_group->getAttribute("memberuid")));
         }
         $REDIS->removeCacheArray("sorted_users", "", $USER->uid);
     }
@@ -138,7 +138,7 @@ private function ensureOrgGroupDoesNotExist()
         $org_group = $LDAP->getOrgGroupEntry($SSO["org"]);
         if ($org_group->exists()) {
             $org_group->delete();
-            assert(!$org_group->exists());
+            ensure(!$org_group->exists());
         }
         $REDIS->removeCacheArray("sorted_orgs", "", $SSO["org"]);
     }
@@ -148,7 +148,7 @@ private function ensureUserNotInPIGroup(UnityGroup $pi_group)
         global $USER, $REDIS;
         if ($pi_group->memberExists($USER)) {
             $pi_group->removeUser($USER);
-            assert(!$pi_group->memberExists($USER));
+            ensure(!$pi_group->memberExists($USER));
         }
         $REDIS->removeCacheArray($pi_group->gid, "members", $USER->uid);
     }
@@ -159,7 +159,7 @@ private function ensurePIGroupDoesNotExist()
         $gid = $USER->getPIGroup()->gid;
         if ($USER->getPIGroup()->exists()) {
             $LDAP->getPIGroupEntry($gid)->delete();
-            assert(!$USER->getPIGroup()->exists());
+            ensure(!$USER->getPIGroup()->exists());
         }
         $REDIS->removeCacheArray("sorted_groups", "", $gid);
     }
diff --git a/test/phpunit-bootstrap.php b/test/phpunit-bootstrap.php
index 1bd49bc3..34b370d9 100644
--- a/test/phpunit-bootstrap.php
+++ b/test/phpunit-bootstrap.php
@@ -20,6 +20,8 @@
 require_once __DIR__ . "/../resources/lib/exceptions/SSOException.php";
 require_once __DIR__ . "/../resources/lib/exceptions/ArrayKeyException.php";
 require_once __DIR__ . "/../resources/lib/exceptions/EnsureException.php";
+require_once __DIR__ . "/../resources/lib/exceptions/EncodingUnknownException.php";
+require_once __DIR__ . "/../resources/lib/exceptions/EncodingConversionException.php";
 
 $_SERVER["HTTP_HOST"] = "phpunit"; // used for config override
 require_once __DIR__ .  "/../resources/config.php";
@@ -48,7 +50,7 @@
     '{"key": "value"}',
     'SGVsbG8sIFdvcmxkIQ==',
     "Hello\x00World",
-    mb_convert_encoding("Hello, World!", "UTF-16")
+    mbConvertEncoding("Hello, World!", "UTF-16")
 ];
 
 function arraysAreEqualUnOrdered(array $a, array $b): bool
@@ -79,7 +81,7 @@ function switchUser(
     $_SERVER["givenName"] = $given_name;
     $_SERVER["sn"] = $sn;
     include __DIR__ .  "/../resources/autoload.php";
-    assert(!is_null($USER));
+    ensure(!is_null($USER));
 }
 
 function http_post(string $phpfile, array $post_data): void
@@ -102,7 +104,7 @@ function http_post(string $phpfile, array $post_data): void
         $_SERVER = $_PREVIOUS_SERVER;
     }
     // https://en.wikipedia.org/wiki/Post/Redirect/Get
-    assert($post_did_redirect_or_die, "post did not redirect or die!");
+    ensure($post_did_redirect_or_die, "post did not redirect or die!");
 }
 
 function http_get(string $phpfile, array $get_data = array()): void
diff --git a/webroot/api/notices/index.php b/webroot/api/notices/index.php
index 9c4eaca9..94076c8f 100644
--- a/webroot/api/notices/index.php
+++ b/webroot/api/notices/index.php
@@ -15,5 +15,5 @@
     $jsonArray[] = $formattedNotice;
 }
 
-$jsonOutput = json_encode($jsonArray, JSON_PRETTY_PRINT);
+$jsonOutput = jsonEncode($jsonArray, JSON_PRETTY_PRINT);
 echo $jsonOutput;
diff --git a/webroot/panel/account.php b/webroot/panel/account.php
index f97a84b6..fa2c5d6f 100644
--- a/webroot/panel/account.php
+++ b/webroot/panel/account.php
@@ -3,6 +3,8 @@
 require_once __DIR__ . "/../../resources/autoload.php";
 
 use UnityWebPortal\lib\UnityHTTPD;
+use UnityWebPortal\lib\exceptions\EncodingUnknownException;
+use UnityWebPortal\lib\exceptions\EncodingConversionException;
 
 $hasGroups = count($USER->getPIGroupGIDs()) > 0;
 
@@ -15,7 +17,11 @@
                     array_push($keys, UnityHTTPD::getPostData("key"));
                     break;
                 case "import":
-                    $key = UnityHTTPD::getUploadedFileContents("keyfile");
+                    try {
+                        $key = UnityHTTPD::getUploadedFileContents("keyfile");
+                    } catch (EncodingUnknownException | EncodingConversionException $e) {
+                        UnityHTTPD::badRequest("uploaded key has bad encoding", error: $e);
+                    }
                     array_push($keys, $key);
                     break;
                 case "generate":