diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -428,6 +428,7 @@
     'phutil_get_library_root' => 'moduleutils/moduleutils.php',
     'phutil_get_library_root_for_path' => 'moduleutils/moduleutils.php',
     'phutil_get_signal_name' => 'future/exec/execx.php',
+    'phutil_hashes_are_identical' => 'utils/utils.php',
     'phutil_implode_html' => 'markup/render.php',
     'phutil_ini_decode' => 'utils/utils.php',
     'phutil_is_hiphop_runtime' => 'utils/utils.php',
diff --git a/src/utils/__tests__/PhutilUtilsTestCase.php b/src/utils/__tests__/PhutilUtilsTestCase.php
--- a/src/utils/__tests__/PhutilUtilsTestCase.php
+++ b/src/utils/__tests__/PhutilUtilsTestCase.php
@@ -792,4 +792,30 @@
     $this->assertTrue(($caught instanceof Exception));
   }
 
+  public function testHashComparisons() {
+    $tests = array(
+      array('1', '12', false),
+      array('0', '0e123', false),
+      array('0e123', '0e124', false),
+      array('', '0', false),
+      array('000', '0e0', false),
+      array('001', '002', false),
+      array('0', '', false),
+      array('987654321', '123456789', false),
+      array('A', 'a', false),
+      array('123456789', '123456789', true),
+      array('hunter42', 'hunter42', true),
+    );
+
+    foreach ($tests as $key => $test) {
+      list($u, $v, $expect) = $test;
+      $actual = phutil_hashes_are_identical($u, $v);
+      $this->assertEqual(
+        $expect,
+        $actual,
+        pht('Test Case: "%s" vs "%s"', $u, $v));
+    }
+  }
+
+
 }
diff --git a/src/utils/utils.php b/src/utils/utils.php
--- a/src/utils/utils.php
+++ b/src/utils/utils.php
@@ -1360,3 +1360,50 @@
   $regex = '(\A'.$regex.'\z)';
   return (bool)preg_match($regex, $path);
 }
+
+
+/**
+ * Compare two hashes for equality.
+ *
+ * This function defuses two attacks: timing attacks and type juggling attacks.
+ *
+ * In a timing attack, the attacker observes that strings which match the
+ * secret take slightly longer to fail to match because more characters are
+ * compared. By testing a large number of strings, they can learn the secret
+ * character by character. This defuses timing attacks by always doing the
+ * same amount of work.
+ *
+ * In a type juggling attack, an attacker takes advantage of PHP's type rules
+ * where `"0" == "0e12345"` for any exponent. A portion of of hexadecimal
+ * hashes match this pattern and are vulnerable. This defuses this attack by
+ * performing bytewise character-by-character comparison.
+ *
+ * It is questionable how practical these attacks are, but they are possible
+ * in theory and defusing them is straightforward.
+ *
+ * @param string First hash.
+ * @param string Second hash.
+ * @return bool True if hashes are identical.
+ */
+function phutil_hashes_are_identical($u, $v) {
+  if (!is_string($u)) {
+    throw new Exception(pht('First hash argument must be a string.'));
+  }
+
+  if (!is_string($v)) {
+    throw new Exception(pht('Second hash argument must be a string.'));
+  }
+
+  if (strlen($u) !== strlen($v)) {
+    return false;
+  }
+
+  $len = strlen($v);
+
+  $bits = 0;
+  for ($ii = 0; $ii < $len; $ii++) {
+    $bits |= (ord($u[$ii]) ^ ord($v[$ii]));
+  }
+
+  return ($bits === 0);
+}