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
@@ -4297,6 +4297,7 @@
     'PhabricatorResourceSite' => 'aphront/site/PhabricatorResourceSite.php',
     'PhabricatorRobotsController' => 'applications/system/controller/PhabricatorRobotsController.php',
     'PhabricatorS3FileStorageEngine' => 'applications/files/engine/PhabricatorS3FileStorageEngine.php',
+    'PhabricatorSMSAuthFactor' => 'applications/auth/factor/PhabricatorSMSAuthFactor.php',
     'PhabricatorSQLPatchList' => 'infrastructure/storage/patch/PhabricatorSQLPatchList.php',
     'PhabricatorSSHKeyGenerator' => 'infrastructure/util/PhabricatorSSHKeyGenerator.php',
     'PhabricatorSSHKeysSettingsPanel' => 'applications/settings/panel/PhabricatorSSHKeysSettingsPanel.php',
@@ -10393,6 +10394,7 @@
     'PhabricatorResourceSite' => 'PhabricatorSite',
     'PhabricatorRobotsController' => 'PhabricatorController',
     'PhabricatorS3FileStorageEngine' => 'PhabricatorFileStorageEngine',
+    'PhabricatorSMSAuthFactor' => 'PhabricatorAuthFactor',
     'PhabricatorSQLPatchList' => 'Phobject',
     'PhabricatorSSHKeyGenerator' => 'Phobject',
     'PhabricatorSSHKeysSettingsPanel' => 'PhabricatorSettingsPanel',
diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php
--- a/src/applications/auth/factor/PhabricatorAuthFactor.php
+++ b/src/applications/auth/factor/PhabricatorAuthFactor.php
@@ -33,7 +33,8 @@
 
   protected function newConfigForUser(PhabricatorUser $user) {
     return id(new PhabricatorAuthFactorConfig())
-      ->setUserPHID($user->getPHID());
+      ->setUserPHID($user->getPHID())
+      ->setFactorSecret('');
   }
 
   protected function newResult() {
@@ -107,6 +108,10 @@
 
     $now = PhabricatorTime::getNow();
 
+    // Factor implementations may need to perform writes in order to issue
+    // challenges, particularly push factors like SMS.
+    $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
+
     $new_challenges = $this->newIssuedChallenges(
       $config,
       $viewer,
@@ -131,10 +136,10 @@
       }
     }
 
-    $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
-      foreach ($new_challenges as $challenge) {
-        $challenge->save();
-      }
+    foreach ($new_challenges as $challenge) {
+      $challenge->save();
+    }
+
     unset($unguarded);
 
     return $new_challenges;
@@ -351,4 +356,36 @@
     return phutil_units('1 hour in seconds');
   }
 
+  final protected function getChallengeForCurrentContext(
+    PhabricatorAuthFactorConfig $config,
+    PhabricatorUser $viewer,
+    array $challenges) {
+
+    $session_phid = $viewer->getSession()->getPHID();
+    $engine = $config->getSessionEngine();
+    $workflow_key = $engine->getWorkflowKey();
+
+    foreach ($challenges as $challenge) {
+      if ($challenge->getSessionPHID() !== $session_phid) {
+        continue;
+      }
+
+      if ($challenge->getWorkflowKey() !== $workflow_key) {
+        continue;
+      }
+
+      if ($challenge->getIsCompleted()) {
+        continue;
+      }
+
+      if ($challenge->getIsReusedChallenge()) {
+        continue;
+      }
+
+      return $challenge;
+    }
+
+    return null;
+  }
+
 }
diff --git a/src/applications/auth/factor/PhabricatorSMSAuthFactor.php b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php
new file mode 100644
--- /dev/null
+++ b/src/applications/auth/factor/PhabricatorSMSAuthFactor.php
@@ -0,0 +1,367 @@
+<?php
+
+final class PhabricatorSMSAuthFactor
+  extends PhabricatorAuthFactor {
+
+  public function getFactorKey() {
+    return 'sms';
+  }
+
+  public function getFactorName() {
+    return pht('SMS');
+  }
+
+  public function getFactorCreateHelp() {
+    return pht(
+      'Allow users to receive a code via SMS.');
+  }
+
+  public function getFactorDescription() {
+    return pht(
+      'When you need to authenticate, a text message with a code will '.
+      'be sent to your phone.');
+  }
+
+  public function getFactorOrder() {
+    // Sort this factor toward the end of the list because SMS is relatively
+    // weak.
+    return 2000;
+  }
+
+  public function canCreateNewProvider() {
+    return $this->isSMSMailerConfigured();
+  }
+
+  public function getProviderCreateDescription() {
+    $messages = array();
+
+    if (!$this->isSMSMailerConfigured()) {
+      $messages[] = id(new PHUIInfoView())
+        ->setErrors(
+          array(
+            pht(
+              'You have not configured an outbound SMS mailer. You must '.
+              'configure one before you can set up SMS. See: %s',
+              phutil_tag(
+                'a',
+                array(
+                  'href' => '/config/edit/cluster.mailers/',
+                ),
+                'cluster.mailers')),
+          ));
+    }
+
+    $messages[] = id(new PHUIInfoView())
+      ->setSeverity(PHUIInfoView::SEVERITY_WARNING)
+      ->setErrors(
+        array(
+          pht(
+            'SMS is weak, and relatively easy for attackers to compromise. '.
+            'Strongly consider using a different MFA provider.'),
+        ));
+
+    return $messages;
+  }
+
+  public function canCreateNewConfiguration(PhabricatorUser $user) {
+    if (!$this->loadUserContactNumber($user)) {
+      return false;
+    }
+
+    return true;
+  }
+
+  public function getConfigurationCreateDescription(PhabricatorUser $user) {
+
+    $messages = array();
+
+    if (!$this->loadUserContactNumber($user)) {
+      $messages[] = id(new PHUIInfoView())
+        ->setSeverity(PHUIInfoView::SEVERITY_WARNING)
+        ->setErrors(
+          array(
+            pht(
+              'You have not configured a primary contact number. Configure '.
+              'a contact number before adding SMS as an authentication '.
+              'factor.'),
+          ));
+    }
+
+    return $messages;
+  }
+
+  public function getEnrollDescription(
+    PhabricatorAuthFactorProvider $provider,
+    PhabricatorUser $user) {
+    return pht(
+      'To verify your phone as an authentication factor, a text message with '.
+      'a secret code will be sent to the phone number you have listed as '.
+      'your primary contact number.');
+  }
+
+  public function getEnrollButtonText(
+    PhabricatorAuthFactorProvider $provider,
+    PhabricatorUser $user) {
+    $contact_number = $this->loadUserContactNumber($user);
+
+    return pht('Send SMS: %s', $contact_number->getDisplayName());
+  }
+
+  public function processAddFactorForm(
+    PhabricatorAuthFactorProvider $provider,
+    AphrontFormView $form,
+    AphrontRequest $request,
+    PhabricatorUser $user) {
+
+    $token = $this->loadMFASyncToken($request, $form, $user);
+    $code = $request->getStr('sms.code');
+
+    $e_code = true;
+    if (!$token->getIsNewTemporaryToken()) {
+      $expect_code = $token->getTemporaryTokenProperty('code');
+
+      $okay = phutil_hashes_are_identical(
+        $this->normalizeSMSCode($code),
+        $this->normalizeSMSCode($expect_code));
+
+      if ($okay) {
+        $config = $this->newConfigForUser($user)
+          ->setFactorName(pht('SMS'));
+
+        return $config;
+      } else {
+        if (!strlen($code)) {
+          $e_code = pht('Required');
+        } else {
+          $e_code = pht('Invalid');
+        }
+      }
+    }
+
+    $form->appendRemarkupInstructions(
+      pht(
+        'Enter the code from the text message which was sent to your '.
+        'primary contact number.'));
+
+    $form->appendChild(
+      id(new PHUIFormNumberControl())
+        ->setLabel(pht('SMS Code'))
+        ->setName('sms.code')
+        ->setValue($code)
+        ->setError($e_code));
+  }
+
+  protected function newIssuedChallenges(
+    PhabricatorAuthFactorConfig $config,
+    PhabricatorUser $viewer,
+    array $challenges) {
+
+    // If we already issued a valid challenge for this workflow and session,
+    // don't issue a new one.
+
+    $challenge = $this->getChallengeForCurrentContext(
+      $config,
+      $viewer,
+      $challenges);
+    if ($challenge) {
+      return array();
+    }
+
+    // Otherwise, issue a new challenge.
+
+    $challenge_code = $this->newSMSChallengeCode();
+    $envelope = new PhutilOpaqueEnvelope($challenge_code);
+    $this->sendSMSCodeToUser($envelope, $viewer);
+
+    $ttl_seconds = phutil_units('15 minutes in seconds');
+
+    return array(
+      $this->newChallenge($config, $viewer)
+        ->setChallengeKey($challenge_code)
+        ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds),
+    );
+  }
+
+  protected function newResultFromIssuedChallenges(
+    PhabricatorAuthFactorConfig $config,
+    PhabricatorUser $viewer,
+    array $challenges) {
+
+    $challenge = $this->getChallengeForCurrentContext(
+      $config,
+      $viewer,
+      $challenges);
+
+    if ($challenge->getIsAnsweredChallenge()) {
+      return $this->newResult()
+        ->setAnsweredChallenge($challenge);
+    }
+
+    return null;
+  }
+
+  public function renderValidateFactorForm(
+    PhabricatorAuthFactorConfig $config,
+    AphrontFormView $form,
+    PhabricatorUser $viewer,
+    PhabricatorAuthFactorResult $result) {
+
+    $control = $this->newAutomaticControl($result);
+    if (!$control) {
+      $value = $result->getValue();
+      $error = $result->getErrorMessage();
+      $name = $this->getChallengeResponseParameterName($config);
+
+      $control = id(new PHUIFormNumberControl())
+        ->setName($name)
+        ->setDisableAutocomplete(true)
+        ->setValue($value)
+        ->setError($error);
+    }
+
+    $control
+      ->setLabel(pht('SMS Code'))
+      ->setCaption(pht('Factor Name: %s', $config->getFactorName()));
+
+    $form->appendChild($control);
+  }
+
+  public function getRequestHasChallengeResponse(
+    PhabricatorAuthFactorConfig $config,
+    AphrontRequest $request) {
+    $value = $this->getChallengeResponseFromRequest($config, $request);
+    return (bool)strlen($value);
+  }
+
+  protected function newResultFromChallengeResponse(
+    PhabricatorAuthFactorConfig $config,
+    PhabricatorUser $viewer,
+    AphrontRequest $request,
+    array $challenges) {
+
+    $challenge = $this->getChallengeForCurrentContext(
+      $config,
+      $viewer,
+      $challenges);
+
+    $code = $this->getChallengeResponseFromRequest(
+      $config,
+      $request);
+
+    $result = $this->newResult()
+      ->setValue($code);
+
+    if ($challenge->getIsAnsweredChallenge()) {
+      return $result->setAnsweredChallenge($challenge);
+    }
+
+    if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) {
+      $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds');
+
+      $challenge
+        ->markChallengeAsAnswered($ttl);
+
+      return $result->setAnsweredChallenge($challenge);
+    }
+
+    if (strlen($code)) {
+      $error_message = pht('Invalid');
+    } else {
+      $error_message = pht('Required');
+    }
+
+    $result->setErrorMessage($error_message);
+
+    return $result;
+  }
+
+  private function newSMSChallengeCode() {
+    $value = Filesystem::readRandomInteger(0, 99999999);
+    $value = sprintf('%08d', $value);
+    return $value;
+  }
+
+  private function isSMSMailerConfigured() {
+    $mailers = PhabricatorMetaMTAMail::newMailers(
+      array(
+        'outbound' => true,
+        'media' => array(
+          PhabricatorMailSMSMessage::MESSAGETYPE,
+        ),
+      ));
+
+    return (bool)$mailers;
+  }
+
+  private function loadUserContactNumber(PhabricatorUser $user) {
+    $contact_numbers = id(new PhabricatorAuthContactNumberQuery())
+      ->setViewer($user)
+      ->withObjectPHIDs(array($user->getPHID()))
+      ->withStatuses(
+        array(
+          PhabricatorAuthContactNumber::STATUS_ACTIVE,
+        ))
+      ->withIsPrimary(true)
+      ->execute();
+
+    if (count($contact_numbers) !== 1) {
+      return null;
+    }
+
+    return head($contact_numbers);
+  }
+
+  protected function newMFASyncTokenProperties(PhabricatorUser $user) {
+    $sms_code = $this->newSMSChallengeCode();
+
+    $envelope = new PhutilOpaqueEnvelope($sms_code);
+    $this->sendSMSCodeToUser($envelope, $user);
+
+    return array(
+      'code' => $sms_code,
+    );
+  }
+
+  private function sendSMSCodeToUser(
+    PhutilOpaqueEnvelope $envelope,
+    PhabricatorUser $user) {
+
+    $uri = PhabricatorEnv::getURI('/');
+    $uri = new PhutilURI($uri);
+
+    return id(new PhabricatorMetaMTAMail())
+      ->setMessageType(PhabricatorMailSMSMessage::MESSAGETYPE)
+      ->addTos(array($user->getPHID()))
+      ->setForceDelivery(true)
+      ->setSensitiveContent(true)
+      ->setBody(
+        pht(
+          'Phabricator (%s) MFA Code: %s',
+          $uri->getDomain(),
+          $envelope->openEnvelope()))
+      ->save();
+  }
+
+  private function normalizeSMSCode($code) {
+    return trim($code);
+  }
+
+  private function getChallengeResponseParameterName(
+    PhabricatorAuthFactorConfig $config) {
+    return $this->getParameterName($config, 'sms.code');
+  }
+
+  private function getChallengeResponseFromRequest(
+    PhabricatorAuthFactorConfig $config,
+    AphrontRequest $request) {
+
+    $name = $this->getChallengeResponseParameterName($config);
+
+    $value = $request->getStr($name);
+    $value = (string)$value;
+    $value = trim($value);
+
+    return $value;
+  }
+
+}
diff --git a/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php b/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php
--- a/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php
+++ b/src/applications/auth/phid/PhabricatorAuthMessagePHIDType.php
@@ -19,7 +19,9 @@
   protected function buildQueryForObjects(
     PhabricatorObjectQuery $query,
     array $phids) {
-    return new PhabricatorAuthMessageQuery();
+
+    return id(new PhabricatorAuthMessageQuery())
+      ->withPHIDs($phids);
   }
 
   public function loadHandles(