diff --git a/resources/static/common/js/provisioning.js b/resources/static/common/js/provisioning.js
index 53859547130ee17a4033c1ae060e879ebbbbf704..7c9ffab4c600172e8d5424dd7ec467121ce9d69d 100644
--- a/resources/static/common/js/provisioning.js
+++ b/resources/static/common/js/provisioning.js
@@ -7,9 +7,13 @@ BrowserID.Provisioning = (function() {
   "use strict";
 
   var jwcrypto = require("./lib/jwcrypto");
+  var MAX_TIMEOUT = 20000; // 20s
 
   var Provisioning = function(args, successCB, failureCB) {
+    var timeoutID;
+
     function tearDown() {
+      if (timeoutID) timeoutID = clearTimeout(timeoutID);
       if (chan) chan.destroy();
       chan = undefined;
       if (iframe) document.body.removeChild(iframe);
@@ -35,8 +39,9 @@ BrowserID.Provisioning = (function() {
     // extract the expected origin from the provisioning url
     // (this may be a different domain than the email domain part, if the
     //  domain delates authority)
+    var origin;
     try {
-      var origin = /^(https?:\/\/[^\/]+)\//.exec(args.url)[1];
+      origin = /^(https?:\/\/[^\/]+)\//.exec(args.url)[1];
     } catch(e) { alert(e); }
     if (!origin) {
       return fail('internal', 'bad provisioning url, can\'t extract origin');
@@ -92,7 +97,10 @@ BrowserID.Provisioning = (function() {
       successCB(keypair, cert);
     });
 
-    // XXX: set a timeout for the amount of time that provisioning is allowed to take
+    // a timeout for the amount of time that provisioning is allowed to take
+    timeoutID = setTimeout(function provisionTimedOut() {
+      fail('timeoutError', 'Provisioning timed out.');
+    }, MAX_TIMEOUT);
   };
 
   return Provisioning;