diff --git a/composer.json b/composer.json
index 6fa56730466ad9b8e437da8543ae308502563c79..f4b0d1eb39efc6475678ab79022834430f8c1be7 100644
--- a/composer.json
+++ b/composer.json
@@ -71,6 +71,7 @@
         "vufind-org/vufinddate": "1.0.0",
         "vufind-org/vufindharvest": "4.0.1",
         "vufind-org/vufindhttp": "3.0.0",
+        "webfontkit/open-sans": "^1.0",
         "wikimedia/composer-merge-plugin": "1.4.1",
         "wikimedia/less.php": "2.0.0",
         "yajra/laravel-pdo-via-oci8": "2.1.1",
diff --git a/composer.lock b/composer.lock
index 7352a1fd4d3fcff363e1bf8666b62c72aab5977b..0ff11420573f42ff1e22794225d75e1a44a00853 100644
--- a/composer.lock
+++ b/composer.lock
@@ -5452,6 +5452,29 @@
             "homepage": "https://vufind.org/",
             "time": "2020-01-27T20:45:14+00:00"
         },
+        {
+            "name": "webfontkit/open-sans",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/webfontkit/open-sans.git",
+                "reference": "00ab31e690edfd0d88f9ffbcd998cf298b9687e9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/webfontkit/open-sans/zipball/00ab31e690edfd0d88f9ffbcd998cf298b9687e9",
+                "reference": "00ab31e690edfd0d88f9ffbcd998cf298b9687e9",
+                "shasum": ""
+            },
+            "type": "library",
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "description": "Open Sans is a humanist sans serif typeface designed by Steve Matteson, Type Director of Ascender Corp. This version contains the complete 897 character set, which includes the standard ISO Latin 1, Latin CE, Greek and Cyrillic character sets. Open Sans was designed with an upright stress, open forms and a neutral, yet friendly appearance. It was optimized for print, web, and mobile interfaces, and has excellent legibility characteristics in its letterforms.",
+            "homepage": "http://www.google.com/fonts/specimen/Open+Sans",
+            "time": "2014-08-20T20:43:34+00:00"
+        },
         {
             "name": "wikimedia/composer-merge-plugin",
             "version": "v1.4.1",
diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index a5039f1e594d08814461b73642f9d96cac06e3d0..6f18d4d4fbf145a2a808c3ee3d6f59248575f434 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -1832,22 +1832,46 @@ treeSearchLimit = 100
 ;EDS = "EBSCO Discovery Service"
 
 ; Activate Captcha validation on select forms
-; VuFind will use reCaptcha validation to prevent bots from using certain actions of
-; your instance. See http://www.google.com/recaptcha for more information on Captcha
-; and create keys for your domain.
-; You will need to provide a sslcapath in the [Http] section for your Captcha to work.
+; VuFind can use Captcha validation to prevent bots from using certain actions of
+; your instance.
 ;[Captcha]
-;siteKey  = "get your reCaptcha key at"
-;secretKey = "https://www.google.com/recaptcha/admin/create"
-; Valid theme values: dark, light
-;theme      = light
-; Valid forms values: changePassword, email, feedback, newAccount, passwordRecovery,
-;                     sms, userComments
-; Use * for all supported forms
+; Valid type values:
+; - figlet (generate a text-based message for the user to interpret)
+; - image (generate a local image for the user to interpret)
+; - recaptcha (use Google's ReCaptcha service)
+; If multiple values are given, the user will be able to pick his favorite.
+; See below for additional type-specific settings.
+;types[] = recaptcha
+
+; The "forms" setting controls the contexts in which CAPTCHA will be presented. It
+; can be either a comma-separated list of forms, or "*" to display CAPTCHA on all
+; supported forms. Valid forms values:
+;       changeEmail, changePassword, email, feedback, newAccount, passwordRecovery,
+;       sms, userComments
 ; Note: when "feedback" is active, Captcha can be conditionally disabled on a
 ;       form-by-form basis with the useCaptcha setting in FeedbackForms.yaml.
-;forms = changeEmail, changePassword, email, newAccount, passwordRecovery, sms
-
+;forms = *
+
+; Figlet options, see:
+;      https://docs.laminas.dev/laminas-captcha/adapters/#laminascaptchafiglet
+;figlet_length = 8
+
+; Image options, see:
+;      https://docs.laminas.dev/laminas-captcha/adapters/#laminascaptchaimage
+;image_length = 8
+;image_width = 200
+;image_height = 50
+;image_fontSize = 24
+;image_dotNoiseLevel = 100
+;image_lineNoiseLevel = 5
+
+; See http://www.google.com/recaptcha for more information on reCAPTCHA and to
+; create keys for your domain. Make sure that SSL settings are correct in the
+; [Http] section, or your Captcha may not work.
+;recaptcha_siteKey  = "get your reCaptcha key at"
+;recaptcha_secretKey = "https://www.google.com/recaptcha/admin/create"
+; Valid theme values: dark, light
+;recaptcha_theme      = light
 
 ; This section can be used to display default text inside the search boxes, useful
 ; for instructions. Format:
diff --git a/languages/ar.ini b/languages/ar.ini
index 89331142c3253266ac78a8b42129dafa6f2613cb..c7df0841fa95110902e2baa26aaec195003ed34e 100644
--- a/languages/ar.ini
+++ b/languages/ar.ini
@@ -176,6 +176,7 @@ callnumber_abbrev = "الطلب #"
 Cannot find record = "لا يمكن العثور على التسجيلة"
 Cannot find similar records = "لا يمكن العثور على تسجيلات مشابهة"
 cannot set = "Cannot set"
+captcha_not_passed = "CAPTCHA لم تمر"
 Cassette = "كاسيت"
 cat_establish_account = "لإنشاء الملف الشخصي لحسابك، يرجى إدخال المعلومات التالية:"
 cat_password_abbrev = "كلمة مرور الفهرس"
@@ -914,7 +915,6 @@ Range = "نطاق"
 Range slider = "تمرير النطاق"
 Read the full review online... = "قراءة المراجعة الكاملة أونلاين..."
 Recall This = "إستدعي هذه النسخة"
-recaptcha_not_passed = "CAPTCHA لم تمر"
 recently_returned_channel_title = "تمت إعادته مؤخرًا"
 recommend_links_text = "كما يمكنك أيضًا تجربة:"
 Record Citations = "استشهادات التسجيلة"
diff --git a/languages/bn.ini b/languages/bn.ini
index 270bfd464c41d24fcac63e15c9d8869dca5a639b..aa4305b0d834979509190f236ff2650e9a0cea5b 100644
--- a/languages/bn.ini
+++ b/languages/bn.ini
@@ -177,6 +177,7 @@ callnumber_abbrev = "ডাক"
 Cannot find record = "নথি খুঁজে পাওয়া যাচ্ছে না"
 Cannot find similar records = "অনুরূপ নথি খুঁজে পাওয়া যাচ্ছে না"
 cannot set = "সেট করা গেলো না ।"
+captcha_not_passed = "CAPTCHA অনুত্তীর্ণ"
 Cassette = "ক্যাসেট"
 cat_establish_account = "আপনার অ্যাকাউন্ট রূপরেখা প্রতিষ্ঠা করার জন্য, নিম্নলিখিত তথ্য লিখুন:"
 cat_password_abbrev = "গ্রন্থ তালিকা পাসওয়ার্ড"
@@ -917,7 +918,6 @@ Range = "ব্যপ্তি"
 Range slider = "ব্যপ্তি স্লাইডার"
 Read the full review online... = "পূর্ণ পর্যালোচনার জন্য অনলাইনে পড়ুন ..."
 Recall This = "মনে করুন"
-recaptcha_not_passed = "CAPTCHA অনুত্তীর্ণ"
 recently_returned_channel_title = "সদ্য ফেরত দেয়া প্রলেখগুলি "
 recommend_links_text = "আপনি অতিরিক্ত চেষ্টা করতে পারেন:"
 Record Citations = "সাইটেশন নথি"
diff --git a/languages/ca.ini b/languages/ca.ini
index e975b9a3986be6d571154e5ff8d31877ff62e2d6..880a21d933f556a1459590295b5549d7c8c5ce22 100644
--- a/languages/ca.ini
+++ b/languages/ca.ini
@@ -187,6 +187,7 @@ callnumber_abbrev = "Signatura #"
 Cannot find record = "No es pot trobar el registre"
 Cannot find similar records = "No es poden trobar registres similars"
 cannot set = "No es pot establir"
+captcha_not_passed = "CAPTCHA invàlid"
 Cassette = "Cassette"
 cat_establish_account = "Per tal d’establir el vostre perfil, introduïu la següent informació:"
 cat_password_abbrev = "Contrasenya del catàleg"
@@ -927,7 +928,6 @@ Range = "Rang"
 Range slider = "Rang mòbil"
 Read the full review online... = "Llegiu la resenya completa en línia..."
 Recall This = "Reclamar aquest"
-recaptcha_not_passed = "CAPTCHA invàlid"
 recently_returned_channel_title = "Retornat fa poc"
 recommend_links_text = "També podeu provar:"
 Record Citations = "Cites del registre"
diff --git a/languages/cs.ini b/languages/cs.ini
index 94fb84132c69d818ed53624811cdb87fbcfeaf2c..f3ad305ab3b835e60606d009fbb328cf04326444 100755
--- a/languages/cs.ini
+++ b/languages/cs.ini
@@ -174,6 +174,7 @@ callnumber_abbrev = "Sign."
 Cannot find record = "Záznam nelze najít"
 Cannot find similar records = "Podobné záznamy nelze najít"
 cannot set = "Nelze nastavit"
+captcha_not_passed = "Kód CAPTCHA nesouhlasí."
 Cassette = "Audio kazeta"
 cat_establish_account = "K vytvoření účtu je nutné zadat následující údaje:"
 cat_password_abbrev = "Heslo"
@@ -914,7 +915,6 @@ Range = "Přidané v období"
 Range slider = "Výběr rozsahu"
 Read the full review online... = "Přečíst celou recenzi online..."
 Recall This = "Rezervovat"
-recaptcha_not_passed = "Kód CAPTCHA nesouhlasí."
 recently_returned_channel_title = "Nedávno vrácené"
 recommend_links_text = "Můžete také zkusit:"
 Record Citations = "Citace záznamu"
diff --git a/languages/cy.ini b/languages/cy.ini
index c84612990baf0da611d25a3682c69acdc3e5e43d..2fbf68466a8110dd902068fc4ef21904f16a4ce4 100644
--- a/languages/cy.ini
+++ b/languages/cy.ini
@@ -170,6 +170,7 @@ Call Number = "Rhif Galw"
 callnumber_abbrev = "Galw"
 Cannot find record = "Methu dod o hyd i'r cofnod"
 Cannot find similar records = "Methu dod o hyd i gofnodion tebyg"
+captcha_not_passed = "Methwyd y prawf CAPTCHA"
 Cassette = "Casét"
 cat_establish_account = "Er mwyn sefydlu proffil eich cyfrif, rhowch y wybodaeth ganlynol i mewn:"
 cat_password_abbrev = "Cyfrinair Catalog"
@@ -817,7 +818,6 @@ Range = "Amrediad"
 Range slider = "Detholwr Amrediad"
 Read the full review online... = "Darllen yr arolwg cyfan ar-lein..."
 Recall This = "Adalw hwn"
-recaptcha_not_passed = "Methwyd y prawf CAPTCHA"
 recently_returned_channel_title = "Dychwelwyd yn Ddiweddar"
 Record Citations = "Cofnodi Dyfyniadau"
 Record Count = "Nifer y Cofnodion"
diff --git a/languages/de.ini b/languages/de.ini
index cd80fb23af93bb4e3067b4d6ec41408ff4268f36..ee36d31bc150db3405cdbe2b9ab6f6ab11f95589 100644
--- a/languages/de.ini
+++ b/languages/de.ini
@@ -175,6 +175,10 @@ callnumber_abbrev = "Sig.: "
 Cannot find record = "Datensatz nicht gefunden"
 Cannot find similar records = "Keine ähnlichen Titel gefunden"
 cannot set = "Kann nicht geändert werden"
+captcha_label_input = "Bitte geben Sie ein was Sie sehen:"
+captcha_label_multiple = "Bitte wählen Sie Ihr bevorzugtes CAPTCHA:"
+captcha_label_single = "CAPTCHA:"
+captcha_not_passed = "CAPTCHA nicht korrekt eingegeben"
 Cassette = "Kassette"
 cat_establish_account = "Um ihr Benutzerkonto einzurichten, hinterlegen Sie bitte folgende Angaben:"
 cat_password_abbrev = "Passwort Katalog"
@@ -913,7 +917,6 @@ Range = "Bereich"
 Range slider = "Bereichswähler"
 Read the full review online... = "Die vollständige Rezension online lesen..."
 Recall This = "Vormerken"
-recaptcha_not_passed = "CAPTCHA nicht korrekt eingegeben"
 recently_returned_channel_title = "Kürzlich zurückgekommen"
 recommend_links_text = "Sie können auch Folgendes versuchen:"
 Record Citations = "Zitate"
diff --git a/languages/el.ini b/languages/el.ini
index 0655ce529bbf9070ce554addc380c771d9857be6..afc3d7d85d2e9cd6c4c2a34df5958f1514fd20eb 100644
--- a/languages/el.ini
+++ b/languages/el.ini
@@ -176,6 +176,7 @@ callnumber_abbrev = "Ταξιθετικός Αριθμός"
 Cannot find record = "Δε βρέθηκε"
 Cannot find similar records = "Δε βρέθηκαν παρόμοια αρχεία"
 cannot set = "Αδυναμία ορισμού"
+captcha_not_passed = "Λανθασμένο CAPTCHA"
 Cassette = "Κασέτα"
 cat_establish_account = "Για να δημιουργήσετε λογαριασμό, παρακαλώ εισάγετε τις παρακάτω πληροφορίες:"
 cat_password_abbrev = "Κωδικός καταλόγου"
@@ -916,7 +917,6 @@ Range = "Εύρος"
 Range slider = "Χειριστήριο εύρους"
 Read the full review online... = "Διαβάστε ολόκληρη την κριτική online..."
 Recall This = "Κάντε ανάκληση"
-recaptcha_not_passed = "Λανθασμένο CAPTCHA"
 recently_returned_channel_title = "Επιστράφηκαν πρόσφατα"
 recommend_links_text = "Μπορείτε επίσης να δοκιμάσετε:"
 Record Citations = "Αναφορές εγγραφής"
diff --git a/languages/en.ini b/languages/en.ini
index 075b9e1bf2a18bdc5d32fac5883a0c4864406ff6..0afe6bbfb4b23407046a3f6b8a769c2caa655c2e 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -178,6 +178,10 @@ callnumber_abbrev = "Call #"
 Cannot find record = "Cannot find record"
 Cannot find similar records = "Cannot find similar records"
 cannot set = "Cannot set"
+captcha_label_input = "Please enter what you see:"
+captcha_label_multiple = "Select your favorite CAPTCHA:"
+captcha_label_single = "CAPTCHA:"
+captcha_not_passed = "CAPTCHA not passed"
 Cassette = "Cassette"
 cat_establish_account = "In order to establish your account profile, please enter the following information:"
 cat_password_abbrev = "Catalog Password"
@@ -934,7 +938,6 @@ Range = "Range"
 Range slider = "Range slider"
 Read the full review online... = "Read the full review online..."
 Recall This = "Recall This"
-recaptcha_not_passed = "CAPTCHA not passed"
 recently_returned_channel_title = "Recently Returned"
 recommend_links_text = "You can also try:"
 Record Citations = "Record Citations"
diff --git a/languages/es.ini b/languages/es.ini
index 23b8c8777e19d112f92cfbd0516e721a0a130a02..30f078ec4e02d217c72db34ed1d0c611f3c012f5 100644
--- a/languages/es.ini
+++ b/languages/es.ini
@@ -176,6 +176,7 @@ callnumber_abbrev = "No. Clasificación"
 Cannot find record = "Registro no encontrado"
 Cannot find similar records = "No se encontraron registros similares"
 cannot set = "No se puede establecer"
+captcha_not_passed = "CAPTCHA inválida"
 Cassette = "Cassette"
 cat_establish_account = "A fin de establecer el perfil de su cuenta, por favor ingrese lo siguiente:"
 cat_password_abbrev = "Clave"
@@ -914,7 +915,6 @@ Range = "Rango"
 Range slider = "Rango de deslizamiento"
 Read the full review online... = "Leer en línea la crítica completa..."
 Recall This = "Recordar esto"
-recaptcha_not_passed = "CAPTCHA inválida"
 recently_returned_channel_title = "Recientemente devuelto"
 recommend_links_text = "También puede probar:"
 Record Citations = "Registro de Citas"
diff --git a/languages/eu.ini b/languages/eu.ini
index 39d05bc8723e6cf6aa5c1c1e88565f5680b54b90..94291044ace30bd94062930051c9f04665813a66 100644
--- a/languages/eu.ini
+++ b/languages/eu.ini
@@ -790,6 +790,7 @@ Call Number = "Sailkapena"
 callnumber_abbrev = "Sailkapen-zenbakia"
 Cannot find record = "Erregistroa ez da aurkitu"
 Cannot find similar records = "Ez da antzeko erregistrorik aurkitu"
+captcha_not_passed = "CAPTCHA ez da gainditu"
 Cassette = "Kasetea"
 cat_establish_account = "Zure kontuaren profila ezartzeko, sartu ondorengoak:"
 cat_password_abbrev = "Pasahitza"
@@ -1436,7 +1437,6 @@ Range = "Maila"
 Range slider = "Rango de Deslizamiento"
 Read the full review online... = "Azterketa osoa linean irakurri"
 Recall This = "Jakinarazi"
-recaptcha_not_passed = "CAPTCHA ez da gainditu"
 recently_returned_channel_title = "Oraintsu itzulita"
 Record Citations = "Dokumentuaren aipamena"
 Record Count = "Erregistro-zenbatzea"
diff --git a/languages/fi.ini b/languages/fi.ini
index f160b1396c2fc7e133ba4f68f37429c150b24faa..91343864f5c00f050388e5088ad803b5748be8c5 100644
--- a/languages/fi.ini
+++ b/languages/fi.ini
@@ -176,6 +176,7 @@ callnumber_abbrev = "Hyllypaikka"
 Cannot find record = "Tietuetta ei löydy"
 Cannot find similar records = "Samankaltaisia tietueita ei löydy"
 cannot set = "Ei voi asettaa"
+captcha_not_passed = "CAPTCHA-tarkistus virheellinen"
 Cassette = "Kasetti"
 cat_establish_account = "Syötä seuraavat tiedot käyttäjätietojen hakua varten:"
 cat_password_abbrev = "Kirjastokortin salasana"
@@ -931,7 +932,6 @@ Range = "Aikaväli"
 Range slider = "Välin säätö"
 Read the full review online... = "Lue arvostelu kokonaisuudessaan..."
 Recall This = "Tee varaus"
-recaptcha_not_passed = "CAPTCHA-tarkistus virheellinen"
 recently_returned_channel_title = "Äskettäin palautetut"
 recommend_links_text = "Voit myös kokeilla:"
 Record Citations = "Tietueen sitaatit"
diff --git a/languages/fr.ini b/languages/fr.ini
index 977a9ddfc35b1b20bae4ae2af84f30e17c903dd8..ed68eb91c4fb5908ef8fc90b8f1557f44fce1fe8 100644
--- a/languages/fr.ini
+++ b/languages/fr.ini
@@ -175,6 +175,7 @@ callnumber_abbrev = "Cote :"
 Cannot find record = "Notice introuvable"
 Cannot find similar records = "Pas de notices similaires trouvées"
 cannot set = "Ne peut pas être modifié"
+captcha_not_passed = "L'identification du CAPTCHA a échoué"
 Cassette = "Cassette"
 cat_establish_account = "Afin de créer un compte d'utilisateur, veuillez saisir les informations suivantes :"
 cat_password_abbrev = "Mot de passe dans le catalogue"
@@ -915,7 +916,6 @@ Range = "Étendue"
 Range slider = "Curseur"
 Read the full review online... = "Lire l'avis complet en ligne..."
 Recall This = "Rappeler"
-recaptcha_not_passed = "L'identification du CAPTCHA a échoué"
 recently_returned_channel_title = "Revenus récemment"
 recommend_links_text = "Vous pouvez également essayer :"
 Record Citations = "Citations de notices"
diff --git a/languages/gl.ini b/languages/gl.ini
index 44a0433c2c1d2d9b8b4d18b495535c9bc76807a1..cf7e112b4955f3e398eaedb46cabd94d6f9765ea 100644
--- a/languages/gl.ini
+++ b/languages/gl.ini
@@ -156,6 +156,7 @@ Call Number = "Número de Clasificación"
 callnumber_abbrev = "No. Clasificación"
 Cannot find record = "Rexistro non atopado"
 Cannot find similar records = "Non se atoparon rexistros similares"
+captcha_not_passed = "CAPTCHA inválida"
 Cassette = "Cassette"
 cat_establish_account = "A fin de establecer o perfil da súa conta, por favor ingrese o seguinte :"
 cat_password_abbrev = "Chave"
@@ -735,7 +736,6 @@ Range = "Rango"
 Range slider = "Rango de deslizamento"
 Read the full review online... = "Ler en liña a crítica completa..."
 Recall This = "Lembrar isto"
-recaptcha_not_passed = "CAPTCHA inválida"
 Record Citations = "Rexistros de Citas"
 Record Count = "Cálculo de rexistro"
 Recover Account = "Recuperar conta"
diff --git a/languages/hi.ini b/languages/hi.ini
index c780f9c586cdd7e8e209b705c9efac73d230893f..e9bf57566b15e6bd2085d6c483033f4118e2219f 100644
--- a/languages/hi.ini
+++ b/languages/hi.ini
@@ -177,6 +177,7 @@ callnumber_abbrev = "कॉल "
 Cannot find record = "रिकॉर्ड नहीं मिल रहा"
 Cannot find similar records = "समान रिकॉर्ड नहीं मिल सकता है"
 cannot set = "सेट नहीं कर सकता"
+captcha_not_passed = "कॅप्चा पास नहीं हुआ"
 Cassette = "कैसेट"
 cat_establish_account = "अपना खाता प्रोफ़ाइल स्थापित करने के लिए, कृपया निम्नलिखित जानकारी दर्ज करें "
 cat_password_abbrev = "कैटलॉग पासवर्ड"
@@ -915,7 +916,6 @@ Range = "सीमा"
 Range slider = " रेंज स्लाइडर"
 Read the full review online... = "पूरी समीक्षा ऑनलाइन पढ़ें ..."
 Recall This = "इसे याद करें "
-recaptcha_not_passed = "कॅप्चा पास नहीं हुआ"
 recently_returned_channel_title = "हाल ही में लौटा"
 recommend_links_text = "आप भी प्रयत्न कर सकते हैं:"
 Record Citations = "रिकॉर्ड उद्धरण"
diff --git a/languages/hr.ini b/languages/hr.ini
index 90a6dc7e122f67a91b18caaeefc975cb24847cff..1c0cf6e38c3ee9d4c6fd1dc75fa6896d166e30b1 100644
--- a/languages/hr.ini
+++ b/languages/hr.ini
@@ -176,6 +176,7 @@ callnumber_abbrev = "Sign."
 Cannot find record = "Nije moguće naći zapis"
 Cannot find similar records = "Nije moguće naći slične zapise"
 cannot set = "Nije moguće postaviti"
+captcha_not_passed = "CAPTCHA provjera nije prošla"
 Cassette = "Kaseta"
 cat_establish_account = "Za postavljanje tvog profila upiši sljedeće informacije:"
 cat_password_abbrev = "Lozinka za katalog"
@@ -916,7 +917,6 @@ Range = "Opseg"
 Range slider = "Klizač za opseg"
 Read the full review online... = "Pročitaj cijelu recenziju online …"
 Recall This = "Rezerviraj ovo"
-recaptcha_not_passed = "CAPTCHA provjera nije prošla"
 recently_returned_channel_title = "Nedavno vraćeno"
 recommend_links_text = "Možeš pokušati i:"
 Record Citations = "Citiranja zapisa"
diff --git a/languages/it.ini b/languages/it.ini
index 8798eb1e4f8231013885c99e8124db7c8819f2eb..aa61df64912319cd9963af4184346169fe7b437a 100644
--- a/languages/it.ini
+++ b/languages/it.ini
@@ -174,6 +174,7 @@ callnumber_abbrev = "Colloc: "
 Cannot find record = "Nessun record trovato"
 Cannot find similar records = "Nessun record simile trovato"
 cannot set = "Impossibile impostare"
+captcha_not_passed = "CAPTCHA non corretto"
 Cassette = "Cassetta"
 cat_establish_account = "Per definire il profilo del tuo account, inserisici le seguenti informazioni:"
 cat_password_abbrev = "Password (catalogo)"
@@ -904,7 +905,6 @@ Range = "Intervallo"
 Range slider = "Scorri la serie"
 Read the full review online... = "Leggi tutta la recensione online..."
 Recall This = "Prenota"
-recaptcha_not_passed = "CAPTCHA non corretto"
 recently_returned_channel_title = "Rientrato di recente"
 recommend_links_text = "Puoi provare anche:"
 Record Citations = "Citazioni del record"
diff --git a/languages/ja.ini b/languages/ja.ini
index b55fef93442a3fb227577fe3023f2b893ae49c15..bfb8b33eaa436f865ae38be46d9ca71ef9f27a8e 100644
--- a/languages/ja.ini
+++ b/languages/ja.ini
@@ -176,6 +176,7 @@ callnumber_abbrev = "請求記号"
 Cannot find record = "該当するレコードが見つかりませんでした"
 Cannot find similar records = "同様なレコードが見つかりませんでした"
 cannot set = "設定不可"
+captcha_not_passed = "CAPTCHAが一致しません"
 Cassette = "カセット"
 cat_establish_account = "次のアカウント情報を入力してください:"
 cat_password_abbrev = "パスワード"
@@ -916,7 +917,6 @@ Range = "検索範囲"
 Range slider = "レンジスライダ"
 Read the full review online... = "書評全文をオンラインで閲覧..."
 Recall This = "返却請求する"
-recaptcha_not_passed = "CAPTCHAが一致しません"
 recently_returned_channel_title = "最新の返却"
 recommend_links_text = "関連リンク:"
 Record Citations = "レコードの引用形"
diff --git a/languages/nl.ini b/languages/nl.ini
index aea14bcd829fb66035e1a793eb7b52f08c4f1aec..68eac5b4e82ac54cf7130c6df914cf9a3852853b 100644
--- a/languages/nl.ini
+++ b/languages/nl.ini
@@ -176,6 +176,7 @@ callnumber_abbrev = "Plaatsingsnr."
 Cannot find record = "Record niet gevonden"
 Cannot find similar records = "Geen gelijkaardige records gevonden"
 cannot set = "Kan niet worden ingesteld"
+captcha_not_passed = "Onjuiste CAPTCHA"
 Cassette = "Cassette"
 cat_establish_account = "Geef alsjeblieft de volgende gegevens in om jouw profiel aan te maken your account profile:"
 cat_password_abbrev = "Catalogus Wachtwoord"
@@ -916,7 +917,6 @@ Range = "Periode"
 Range slider = "Reikwijdte schuifbalk"
 Read the full review online... = "Lees volledige bespreking online..."
 Recall This = "Hou dit vast"
-recaptcha_not_passed = "Onjuiste CAPTCHA"
 recently_returned_channel_title = "Onlangs ingeleverd"
 recommend_links_text = "Je kunt ook proberen:"
 Record Citations = "Record citatie"
diff --git a/languages/pl.ini b/languages/pl.ini
index 5e7a6d4e71e83343d2054ebf0b413fe497df57f2..a2b1887abb08330f50bf356041fe9a9ac62a1ec2 100644
--- a/languages/pl.ini
+++ b/languages/pl.ini
@@ -237,6 +237,7 @@ callnumber_abbrev = "KN #"
 Cannot find record = "Nie znaleziono rekordu"
 Cannot find similar records = "Nie znaleziono podobnych rekordów"
 cannot set = "Nie można ustawić"
+captcha_not_passed = "CAPTCHA nie zweryfikowany"
 Cassette = "Kaseta"
 cat_establish_account = "Aby założyć konto, podaj następujące dane:"
 cat_password_abbrev = "Hasło"
@@ -975,7 +976,6 @@ Range = "Okres czasu"
 Range slider = "Slider"
 Read the full review online... = "Przeczytaj całą recenzję..."
 Recall This = "Odwołaj"
-recaptcha_not_passed = "CAPTCHA nie zweryfikowany"
 recently_returned_channel_title = "Niedawno zwrócone"
 recommend_links_text = "Spróbuj także:"
 Record Citations = "Cytaty zapisu"
diff --git a/languages/pt-br.ini b/languages/pt-br.ini
index 2671eacb1c0ff3b723af743c581565c0372584cf..3e499f3bd30adec18a2b250d655c4895d2187bdb 100644
--- a/languages/pt-br.ini
+++ b/languages/pt-br.ini
@@ -178,6 +178,7 @@ callnumber_abbrev = "Cota #"
 Cannot find record = "Registro não encontrado"
 Cannot find similar records = "Registros relacionados não encontrados"
 cannot set = "Não é possível definir"
+captcha_not_passed = "Código CAPTCHA não coincidiu"
 Cassette = "Cassete"
 cat_establish_account = "Para poder criar uma conta, por favor insira a seguinte informação:"
 cat_password_abbrev = "Senha do catálogo"
@@ -933,7 +934,6 @@ Range = "Intervalo"
 Range slider = "Ajuste do Intervalo"
 Read the full review online... = "Leia a análise completa linha ..."
 Recall This = "Reservar"
-recaptcha_not_passed = "Código CAPTCHA não coincidiu"
 recently_returned_channel_title = "Recentemente Devolvidos"
 recommend_links_text = "Você também pode tentar:"
 Record Citations = "Citações do registro"
diff --git a/languages/pt.ini b/languages/pt.ini
index 9e67556c4ef2286a90a1cc6162850fbc879fede6..167ad908821fb5dd3916ec4a2186a70dfc4319d1 100644
--- a/languages/pt.ini
+++ b/languages/pt.ini
@@ -153,6 +153,7 @@ Call Number = "Área/Cota"
 callnumber_abbrev = "Cota #"
 Cannot find record = "Registo não encontrado"
 Cannot find similar records = "Registos relacionados não encontrados"
+captcha_not_passed = "CAPTCHA errada"
 Cassette = "Cassete"
 cat_establish_account = "Para poder criar uma conta, por favor insira a seguinte informação:"
 cat_password_abbrev = "Senha do catálogo"
@@ -710,7 +711,6 @@ Range = "Intervalo"
 Range slider = "Ajuste do Intervalo"
 Read the full review online... = "Leia a análise completa linha ..."
 Recall This = "Reservar"
-recaptcha_not_passed = "CAPTCHA errada"
 Record Citations = "Citações do registo"
 Record Count = "Número de Registos"
 Recover Account = "Recuperar Conta"
diff --git a/languages/ru.ini b/languages/ru.ini
index 88e8b1e78f588afbadfcbf1c552e0d758f1121bd..3b8ecb48de2dc4074aa00b5c1805382406f5f29d 100644
--- a/languages/ru.ini
+++ b/languages/ru.ini
@@ -176,6 +176,7 @@ Call Number = "Шифр"
 callnumber_abbrev = "Шифр №"
 Cannot find record = "Невозможно найти запись"
 Cannot find similar records = "Невозможно найти аналогичную запись"
+captcha_not_passed = "CAPTCHA не передана"
 Cassette = "Касета"
 cat_establish_account = "Для установки своего профиля введите следующие данные:"
 cat_password_abbrev = "Пароль каталога"
@@ -769,7 +770,6 @@ Range = "Диапазон"
 Range slider = "Движок диапазонов"
 Read the full review online... = "Чтение полного обзора в режиме online..."
 Recall This = "Напомнить: "
-recaptcha_not_passed = "CAPTCHA не передана"
 Record Citations = "Цитаты записи:"
 Record Count = "Число записей"
 Recover Account = "Восстановить акаунт"
diff --git a/languages/sv.ini b/languages/sv.ini
index a404aa83b9a39342de81395348a5751982284c35..ebcadcc45537fdc1cfef60f7a8fc680d7d266d4d 100644
--- a/languages/sv.ini
+++ b/languages/sv.ini
@@ -176,6 +176,7 @@ callnumber_abbrev = "Signum"
 Cannot find record = "Posten hittas inte"
 Cannot find similar records = "Inga liknande poster hittades"
 cannot set = "Kan inte sätta"
+captcha_not_passed = "CAPTCHA inte klarat"
 Cassette = "Kassett"
 cat_establish_account = "Ange följande uppgifter för att hämta din användarinformation:"
 cat_password_abbrev = "Lösenord i bibliotekssystemet"
@@ -926,7 +927,6 @@ Range = "Tidsintervall"
 Range slider = "Justera intervallet"
 Read the full review online... = "Läs hela recensionen online..."
 Recall This = "Reservera denna"
-recaptcha_not_passed = "CAPTCHA inte klarat"
 recently_returned_channel_title = "Nyligen returnerad"
 recommend_links_text = "Du kan försöka också:"
 Record Citations = "Hänvisningar gällande denna post"
diff --git a/languages/tr.ini b/languages/tr.ini
index 90b9115fa764a5be522f3783aa989cca6ad865e9..a693e9af7f1763198e9c4271de9c910c90baca58 100644
--- a/languages/tr.ini
+++ b/languages/tr.ini
@@ -186,6 +186,7 @@ callnumber_abbrev = "Yer Numarası Kısaltması"
 Cannot find record = "Kayıt bulunamıyor"
 Cannot find similar records = "Benzer kayıt bulunamadı"
 cannot set = "Ayarlanamýyor"
+captcha_not_passed = "CAPTCHA kodu uygun deÄŸil"
 Cassette = "Kaset"
 cat_establish_account = "Kullanıcı hesabınızla bağlantı kurulamadığından, lütfen takip eden bilgileri doldurun:"
 cat_password_abbrev = "Katalog Åžifresi"
@@ -924,7 +925,6 @@ Range = "Aralık"
 Range slider = "Sınırlandırma Düzeyi"
 Read the full review online... = "Eleştirinin tamamını online oku..."
 Recall This = "Rezerve"
-recaptcha_not_passed = "CAPTCHA kodu uygun deÄŸil"
 recently_returned_channel_title = "Son Zamanlarda Ä°ade Edilenler"
 recommend_links_text = "Ayrýca deneyebilirsiniz:"
 Record Citations = "Alıntılar"
diff --git a/languages/vi.ini b/languages/vi.ini
index acb62c4e8ce72d93011b3d75832351b19dae6939..bcabc32a1832256b4794d7ba757e0664d88276fb 100644
--- a/languages/vi.ini
+++ b/languages/vi.ini
@@ -175,6 +175,7 @@ callnumber_abbrev = "Gọi #"
 Cannot find record = "Không thể tìm bản ghi"
 Cannot find similar records = "Không tìm ra được bản ghi tương tự"
 cannot set = "Không thể thiết lập"
+captcha_not_passed = "CAPTCHA không được thông qua"
 Cassette = "Băng cát xét"
 cat_establish_account = "Để thiết lập hồ sơ tài khoản của bạn, xin vui lòng nhập thông tin sau:"
 cat_password_abbrev = "Ca-ta-lô mật khẩu"
@@ -913,7 +914,6 @@ Range = "Phạm vi"
 Range slider = "Phạm vi thanh trượt "
 Read the full review online... = "Đọc toàn bộ đánh giá trực tuyến ..."
 Recall This = "Gọi lại điều này"
-recaptcha_not_passed = "CAPTCHA không được thông qua"
 recently_returned_channel_title = "Đã trả lại gần đây"
 recommend_links_text = "Bạn cũng có thể thử:"
 Record Citations = "Ghi lại trích dẫn"
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index c1ee018af9c4986de69bd6e3ba86c17ab7b21a2d..a0413047782a3f381f4270d0048afd3382d4c447 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -297,6 +297,7 @@ $config = [
     ],
     'controller_plugins' => [
         'factories' => [
+            'VuFind\Controller\Plugin\Captcha' => 'VuFind\Controller\Plugin\CaptchaFactory',
             'VuFind\Controller\Plugin\DbUpgrade' => 'Laminas\ServiceManager\Factory\InvokableFactory',
             'VuFind\Controller\Plugin\Favorites' => 'VuFind\Controller\Plugin\FavoritesFactory',
             'VuFind\Controller\Plugin\Followup' => 'VuFind\Controller\Plugin\FollowupFactory',
@@ -304,7 +305,6 @@ $config = [
             'VuFind\Controller\Plugin\ILLRequests' => 'VuFind\Controller\Plugin\AbstractRequestBaseFactory',
             'VuFind\Controller\Plugin\NewItems' => 'VuFind\Controller\Plugin\NewItemsFactory',
             'VuFind\Controller\Plugin\Permission' => 'VuFind\Controller\Plugin\PermissionFactory',
-            'VuFind\Controller\Plugin\Recaptcha' => 'VuFind\Controller\Plugin\RecaptchaFactory',
             'VuFind\Controller\Plugin\Renewals' => 'Laminas\ServiceManager\Factory\InvokableFactory',
             'VuFind\Controller\Plugin\Reserves' => 'VuFind\Controller\Plugin\ReservesFactory',
             'VuFind\Controller\Plugin\ResultScroller' => 'VuFind\Controller\Plugin\ResultScrollerFactory',
@@ -315,6 +315,7 @@ $config = [
             'VuFind\ServiceManager\ServiceInitializer',
         ],
         'aliases' => [
+            'captcha' => 'VuFind\Controller\Plugin\Captcha',
             'dbUpgrade' => 'VuFind\Controller\Plugin\DbUpgrade',
             'favorites' => 'VuFind\Controller\Plugin\Favorites',
             'flashMessenger' => 'Laminas\Mvc\Plugin\FlashMessenger\FlashMessenger',
@@ -323,7 +324,6 @@ $config = [
             'ILLRequests' => 'VuFind\Controller\Plugin\ILLRequests',
             'newItems' => 'VuFind\Controller\Plugin\NewItems',
             'permission' => 'VuFind\Controller\Plugin\Permission',
-            'recaptcha' => 'VuFind\Controller\Plugin\Recaptcha',
             'renewals' => 'VuFind\Controller\Plugin\Renewals',
             'reserves' => 'VuFind\Controller\Plugin\Reserves',
             'resultScroller' => 'VuFind\Controller\Plugin\ResultScroller',
@@ -343,6 +343,7 @@ $config = [
             'VuFind\Autocomplete\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
             'VuFind\Autocomplete\Suggester' => 'VuFind\Autocomplete\SuggesterFactory',
             'VuFind\Cache\Manager' => 'VuFind\Cache\ManagerFactory',
+            'VuFind\Captcha\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
             'VuFind\Cart' => 'VuFind\CartFactory',
             'VuFind\ChannelProvider\ChannelLoader' => 'VuFind\ChannelProvider\ChannelLoaderFactory',
             'VuFind\ChannelProvider\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
@@ -549,6 +550,7 @@ $config = [
             'ajaxhandler' => [ /* see VuFind\AjaxHandler\PluginManager for defaults */ ],
             'auth' => [ /* see VuFind\Auth\PluginManager for defaults */ ],
             'autocomplete' => [ /* see VuFind\Autocomplete\PluginManager for defaults */ ],
+            'captcha' => [ /* see VuFind\Captcha\PluginManager for defaults */ ],
             'channelprovider' => [ /* see VuFind\ChannelProvider\PluginManager for defaults */ ],
             'content' => [ /* see VuFind\Content\PluginManager for defaults */ ],
             'content_authornotes' => [ /* see VuFind\Content\AuthorNotes\PluginManager for defaults */ ],
diff --git a/module/VuFind/src/VuFind/AjaxHandler/CommentRecord.php b/module/VuFind/src/VuFind/AjaxHandler/CommentRecord.php
index 2ebf3b751a32d24fd26982f260681ae0ca05c5ee..58efa202b44243a52a4fb838694243fc78289aa1 100644
--- a/module/VuFind/src/VuFind/AjaxHandler/CommentRecord.php
+++ b/module/VuFind/src/VuFind/AjaxHandler/CommentRecord.php
@@ -28,7 +28,7 @@
 namespace VuFind\AjaxHandler;
 
 use Laminas\Mvc\Controller\Plugin\Params;
-use VuFind\Controller\Plugin\Recaptcha;
+use VuFind\Controller\Plugin\Captcha;
 use VuFind\Db\Row\User;
 use VuFind\Db\Table\Resource;
 use VuFind\I18n\Translator\TranslatorAwareInterface;
@@ -54,11 +54,11 @@ class CommentRecord extends AbstractBase implements TranslatorAwareInterface
     protected $table;
 
     /**
-     * Recaptcha controller plugin
+     * Captcha controller plugin
      *
-     * @var Recaptcha
+     * @var Captcha
      */
-    protected $recaptcha;
+    protected $captcha;
 
     /**
      * Logged in user (or false)
@@ -77,16 +77,16 @@ class CommentRecord extends AbstractBase implements TranslatorAwareInterface
     /**
      * Constructor
      *
-     * @param Resource  $table     Resource database table
-     * @param Recaptcha $recaptcha Recaptcha controller plugin
-     * @param User|bool $user      Logged in user (or false)
-     * @param bool      $enabled   Are comments enabled?
+     * @param Resource  $table   Resource database table
+     * @param Captcha   $captcha Captcha controller plugin
+     * @param User|bool $user    Logged in user (or false)
+     * @param bool      $enabled Are comments enabled?
      */
-    public function __construct(Resource $table, Recaptcha $recaptcha, $user,
+    public function __construct(Resource $table, Captcha $captcha, $user,
         $enabled = true
     ) {
         $this->table = $table;
-        $this->recaptcha = $recaptcha;
+        $this->captcha = $captcha;
         $this->user = $user;
         $this->enabled = $enabled;
     }
@@ -99,11 +99,11 @@ class CommentRecord extends AbstractBase implements TranslatorAwareInterface
     protected function checkCaptcha()
     {
         // Not enabled? Report success!
-        if (!$this->recaptcha->active('userComments')) {
+        if (!$this->captcha->active('userComments')) {
             return true;
         }
-        $this->recaptcha->setErrorMode('none');
-        return $this->recaptcha->validate();
+        $this->captcha->setErrorMode('none');
+        return $this->captcha->verify();
     }
 
     /**
@@ -142,7 +142,7 @@ class CommentRecord extends AbstractBase implements TranslatorAwareInterface
 
         if (!$this->checkCaptcha()) {
             return $this->formatResponse(
-                $this->translate('recaptcha_not_passed'),
+                $this->translate('captcha_not_passed'),
                 self::STATUS_HTTP_FORBIDDEN
             );
         }
diff --git a/module/VuFind/src/VuFind/AjaxHandler/CommentRecordFactory.php b/module/VuFind/src/VuFind/AjaxHandler/CommentRecordFactory.php
index b93db0c70a27d93220bf8b9286cb7f979cdf337d..d26fa36cf868a8737bef2ab3e84df152939d10dd 100644
--- a/module/VuFind/src/VuFind/AjaxHandler/CommentRecordFactory.php
+++ b/module/VuFind/src/VuFind/AjaxHandler/CommentRecordFactory.php
@@ -69,7 +69,7 @@ class CommentRecordFactory
         return new $requestedName(
             $tablePluginManager->get(\VuFind\Db\Table\Resource::class),
             $controllerPluginManager
-                ->get(\VuFind\Controller\Plugin\Recaptcha::class),
+                ->get(\VuFind\Controller\Plugin\Captcha::class),
             $container->get(\VuFind\Auth\Manager::class)->isLoggedIn(),
             $capabilities->getCommentSetting() !== 'disabled'
         );
diff --git a/module/VuFind/src/VuFind/Captcha/AbstractBase.php b/module/VuFind/src/VuFind/Captcha/AbstractBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..a42b0878958283badcb47541ba3d03d9120084e0
--- /dev/null
+++ b/module/VuFind/src/VuFind/Captcha/AbstractBase.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Abstract base CAPTCHA
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Captcha;
+
+use Laminas\Mvc\Controller\Plugin\Params;
+
+/**
+ * Abstract base CAPTCHA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class AbstractBase
+{
+    /**
+     * Get list of URLs with JS dependancies to load for the active CAPTCHA type.
+     *
+     * @return array
+     */
+    public function getJsIncludes(): array
+    {
+        return [];
+    }
+
+    /**
+     * Get ID for current CAPTCHA (to use e.g. in HTML forms)
+     *
+     * @return string
+     */
+    public function getId(): string
+    {
+        return preg_replace('"^.*\\\\"', '', get_class($this));
+    }
+
+    /**
+     * Pull the captcha field from controller params and check them for accuracy
+     *
+     * @param Params $params Controller params
+     *
+     * @return bool
+     */
+    abstract public function verify(Params $params): bool;
+}
diff --git a/module/VuFind/src/VuFind/View/Helper/Bootstrap3/Recaptcha.php b/module/VuFind/src/VuFind/Captcha/Demo.php
similarity index 57%
rename from module/VuFind/src/VuFind/View/Helper/Bootstrap3/Recaptcha.php
rename to module/VuFind/src/VuFind/Captcha/Demo.php
index 9bacef65db4549522e511c8ec5efb75b9604b261..6bfa1ffabbe9425db1382efeae035362c835e1d0 100644
--- a/module/VuFind/src/VuFind/View/Helper/Bootstrap3/Recaptcha.php
+++ b/module/VuFind/src/VuFind/Captcha/Demo.php
@@ -1,10 +1,10 @@
 <?php
 /**
- * Recaptcha view helper
+ * Demo CAPTCHA (expect hard-coded value; used for test suite only).
  *
  * PHP version 7
  *
- * Copyright (C) Villanova University 2016.
+ * Copyright (C) Villanova University 2020.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -20,34 +20,35 @@
  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
  *
  * @category VuFind
- * @package  View_Helpers
- * @author   Chris Hallberg <crhallberg@gmail.com>
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development Wiki
+ * @link     https://vufind.org Main Page
  */
-namespace VuFind\View\Helper\Bootstrap3;
+namespace VuFind\Captcha;
+
+use Laminas\Mvc\Controller\Plugin\Params;
 
 /**
- * Recaptcha view helper
+ * Demo CAPTCHA (expect hard-coded value; used for test suite only).
  *
  * @category VuFind
- * @package  View_Helpers
- * @author   Chris Hallberg <crhallberg@gmail.com>
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development Wiki
  */
-class Recaptcha extends \VuFind\View\Helper\Root\Recaptcha
+class Demo extends AbstractBase
 {
     /**
-     * Constructor
+     * Pull the captcha field from controller params and check them for accuracy
+     *
+     * @param Params $params Controller params
      *
-     * @param \Laminas\Recaptcha\Recaptcha $rc     Custom formatted Recaptcha
-     * @param \VuFind\Config               $config Config object
+     * @return bool
      */
-    public function __construct($rc, $config)
+    public function verify(Params $params): bool
     {
-        $this->prefixHtml = '<div class="form-group">';
-        $this->suffixHtml = '</div>';
-        parent::__construct($rc, $config);
+        return $params->fromPost('demo_captcha') === 'demo';
     }
 }
diff --git a/module/VuFind/src/VuFind/Captcha/Figlet.php b/module/VuFind/src/VuFind/Captcha/Figlet.php
new file mode 100644
index 0000000000000000000000000000000000000000..b1a22cb5a6d42a9a8e069a6891179f113e90f8f4
--- /dev/null
+++ b/module/VuFind/src/VuFind/Captcha/Figlet.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Figlet CAPTCHA.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFind\Captcha;
+
+/**
+ * Figlet CAPTCHA.
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class Figlet extends LaminasBase
+{
+}
diff --git a/module/VuFind/src/VuFind/Captcha/FigletFactory.php b/module/VuFind/src/VuFind/Captcha/FigletFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..1613cbb35da1ed221d77e144ba997cd9b4efffff
--- /dev/null
+++ b/module/VuFind/src/VuFind/Captcha/FigletFactory.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * Factory for Figlet CAPTCHA module.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Captcha;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Figlet CAPTCHA factory.
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class FigletFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+
+        $figletOptions = [
+            'name' => 'figlet_captcha',
+        ];
+
+        $config = $container->get(\VuFind\Config\PluginManager::class)
+            ->get('config');
+
+        if (isset($config->Captcha->figlet_length)) {
+            $figletOptions['wordLen'] = $config->Captcha->figlet_length;
+        }
+
+        return new $requestedName(
+            new \Laminas\Captcha\Figlet($figletOptions)
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/Captcha/Image.php b/module/VuFind/src/VuFind/Captcha/Image.php
new file mode 100644
index 0000000000000000000000000000000000000000..c7db8aa5b4698aeea57dd2cb7b56e5db2c07c797
--- /dev/null
+++ b/module/VuFind/src/VuFind/Captcha/Image.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Image CAPTCHA.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFind\Captcha;
+
+/**
+ * Image CAPTCHA.
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class Image extends LaminasBase
+{
+    /**
+     * Base path of cache where image will be stored, e.g. /vufind/cache/
+     *
+     * @var string
+     */
+    protected $cacheBasePath;
+
+    /**
+     * Constructor
+     *
+     * @param \Laminas\Captcha\AbstractWord $captcha       Laminas CAPTCHA object
+     * @param string                        $cacheBasePath e.g. /vufind/cache/
+     */
+    public function __construct(\Laminas\Captcha\AbstractWord $captcha,
+        string $cacheBasePath
+    ) {
+        $this->cacheBasePath = $cacheBasePath;
+        parent::__construct($captcha);
+    }
+
+    /**
+     * Getter for template
+     *
+     * @return string
+     */
+    public function getCacheBasePath(): string
+    {
+        return $this->cacheBasePath;
+    }
+}
diff --git a/module/VuFind/src/VuFind/Captcha/ImageFactory.php b/module/VuFind/src/VuFind/Captcha/ImageFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..be42054fbf62e2b4f58a6e2624e4c8a2fc9da3fc
--- /dev/null
+++ b/module/VuFind/src/VuFind/Captcha/ImageFactory.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Factory for Image CAPTCHA module.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Captcha;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Image CAPTCHA factory.
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ImageFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options passed to factory.');
+        }
+
+        $imageOptions = [
+            'font' => APPLICATION_PATH
+                    . '/vendor/webfontkit/open-sans/fonts/opensans-regular.ttf',
+            'imgDir' => $container->get(\VuFind\Cache\Manager::class)
+                ->getCache('public')->getOptions()->getCacheDir()
+        ];
+
+        $config = $container->get(\VuFind\Config\PluginManager::class)
+            ->get('config');
+
+        if (isset($config->Captcha->image_length)) {
+            $imageOptions['wordLen'] = $config->Captcha->image_length;
+        }
+        if (isset($config->Captcha->image_width)) {
+            $imageOptions['width'] = $config->Captcha->image_width;
+        }
+        if (isset($config->Captcha->image_height)) {
+            $imageOptions['height'] = $config->Captcha->image_height;
+        }
+        if (isset($config->Captcha->image_fontSize)) {
+            $imageOptions['fsize'] = $config->Captcha->image_fontSize;
+        }
+        if (isset($config->Captcha->image_dotNoiseLevel)) {
+            $imageOptions['dotNoiseLevel'] = $config->Captcha->image_dotNoiseLevel;
+        }
+        if (isset($config->Captcha->image_lineNoiseLevel)) {
+            $imageOptions['lineNoiseLevel'] = $config->Captcha->image_lineNoiseLevel;
+        }
+
+        return new $requestedName(
+            new \Laminas\Captcha\Image($imageOptions),
+            $container->get('ViewHelperManager')->get('url')->__invoke('home')
+                . '/cache/'
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/Captcha/LaminasBase.php b/module/VuFind/src/VuFind/Captcha/LaminasBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..b181485b711140b37527aae7d995644723ab82fc
--- /dev/null
+++ b/module/VuFind/src/VuFind/Captcha/LaminasBase.php
@@ -0,0 +1,121 @@
+<?php
+/**
+ * Laminas base CAPTCHA
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Captcha;
+
+use Laminas\Mvc\Controller\Plugin\Params;
+
+/**
+ * Laminas base CAPTCHA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+abstract class LaminasBase extends AbstractBase
+{
+    /**
+     * Laminas CAPTCHA object
+     *
+     * @var \Laminas\Captcha\AbstractWord
+     */
+    protected $captcha;
+
+    /**
+     * HTML input name for generated captcha
+     *
+     * @var string
+     */
+    protected $captchaHtmlInternalId = 'captcha-id';
+
+    /**
+     * HTML input name for user input
+     *
+     * @var string
+     */
+    protected $captchaHtmlInputId = 'captcha-input';
+
+    /**
+     * Constructor
+     *
+     * @param \Laminas\Captcha\AbstractWord $captcha Laminas CAPTCHA object
+     */
+    public function __construct(\Laminas\Captcha\AbstractWord $captcha)
+    {
+        $this->captcha = $captcha;
+        $this->captchaHtmlInputId .= '-' . $this->getId();
+        $this->captchaHtmlInternalId .= '-' . $this->getId();
+    }
+
+    /**
+     * Pull the captcha field from controller params and check them for accuracy
+     *
+     * @param Params $params Controller params
+     *
+     * @return bool
+     */
+    public function verify(Params $params): bool
+    {
+        $validateParams = [
+            'id' => $params->fromPost($this->captchaHtmlInternalId),
+            'input' => $params->fromPost($this->captchaHtmlInputId),
+        ];
+        return $this->captcha->isValid($validateParams);
+    }
+
+    /**
+     * Laminas CAPTCHA object
+     *
+     * @return \Laminas\Captcha\AbstractWord
+     */
+    public function getCaptcha(): \Laminas\Captcha\AbstractWord
+    {
+        return $this->captcha;
+    }
+
+    /**
+     * Getter for template
+     *
+     * @return string
+     */
+    public function getHtmlInternalId(): string
+    {
+        return $this->captchaHtmlInternalId;
+    }
+
+    /**
+     * Getter for template
+     *
+     * @return string
+     */
+    public function getHtmlInputId(): string
+    {
+        return $this->captchaHtmlInputId;
+    }
+}
diff --git a/module/VuFind/src/VuFind/Captcha/PluginManager.php b/module/VuFind/src/VuFind/Captcha/PluginManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..c00ba8894906bd0c069749026fc26bab3a0b6238
--- /dev/null
+++ b/module/VuFind/src/VuFind/Captcha/PluginManager.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * CAPTCHA plugin manager
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Captcha;
+
+use Laminas\ServiceManager\Factory\InvokableFactory;
+
+/**
+ * CAPTCHA plugin manager
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
+{
+    /**
+     * Default plugin aliases.
+     *
+     * @var array
+     */
+    protected $aliases = [
+        'demo' => Demo::class,
+        'figlet' => Figlet::class,
+        'image' => Image::class,
+        'recaptcha' => ReCaptcha::class,
+    ];
+
+    /**
+     * Default plugin factories.
+     *
+     * @var array
+     */
+    protected $factories = [
+        Demo::class => InvokableFactory::class,
+        Figlet::class => FigletFactory::class,
+        Image::class => ImageFactory::class,
+        ReCaptcha::class => ReCaptchaFactory::class,
+    ];
+
+    /**
+     * Return the name of the base class or interface that plug-ins must conform
+     * to.
+     *
+     * @return string
+     */
+    protected function getExpectedInterface()
+    {
+        return AbstractBase::class;
+    }
+}
diff --git a/module/VuFind/src/VuFind/Captcha/ReCaptcha.php b/module/VuFind/src/VuFind/Captcha/ReCaptcha.php
new file mode 100644
index 0000000000000000000000000000000000000000..8835c78d5d6e300ede23bfb45eeb6ed9b27fb5f9
--- /dev/null
+++ b/module/VuFind/src/VuFind/Captcha/ReCaptcha.php
@@ -0,0 +1,103 @@
+<?php
+/**
+ * ReCaptcha CAPTCHA.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFind\Captcha;
+
+use Laminas\Mvc\Controller\Plugin\Params;
+
+/**
+ * ReCaptcha CAPTCHA.
+ *
+ * @category VuFind
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class ReCaptcha extends AbstractBase
+{
+    /**
+     * ReCaptcha Service.
+     *
+     * @var \VuFind\Service\ReCaptcha
+     */
+    protected $recaptcha;
+
+    /**
+     * Language
+     *
+     * @var string
+     */
+    protected $language;
+
+    /**
+     * Constructor
+     *
+     * @param \VuFind\Service\ReCaptcha $recaptcha ReCaptcha Service
+     * @param string                    $language  Translator locale
+     */
+    public function __construct(\VuFind\Service\ReCaptcha $recaptcha,
+        string $language
+    ) {
+        $this->recaptcha = $recaptcha;
+        $this->language = $language;
+    }
+
+    /**
+     * Get list of URLs with JS dependancies to load for the active CAPTCHA type.
+     *
+     * @return array
+     */
+    public function getJsIncludes(): array
+    {
+        return ['https://www.google.com/recaptcha/api.js'
+              . '?onload=recaptchaOnLoad&render=explicit&hl=' . $this->language];
+    }
+
+    /**
+     * Generate HTML depending on CAPTCHA type.
+     *
+     * @return string
+     */
+    public function getHtml(): string
+    {
+        return $this->recaptcha->getHtml();
+    }
+
+    /**
+     * Pull the captcha field from controller params and check them for accuracy
+     *
+     * @param Params $params Controller params
+     *
+     * @return bool
+     */
+    public function verify(Params $params): bool
+    {
+        $responseField = $params->fromPost('g-recaptcha-response');
+        return $this->recaptcha->verify($responseField)->isValid();
+    }
+}
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/RecaptchaFactory.php b/module/VuFind/src/VuFind/Captcha/ReCaptchaFactory.php
similarity index 79%
rename from module/VuFind/src/VuFind/View/Helper/Root/RecaptchaFactory.php
rename to module/VuFind/src/VuFind/Captcha/ReCaptchaFactory.php
index a8c6f36b1eb6624a309bc2c0ff8c7b8c01179e7f..140ab8c13a6afe01c2b374b2eb279071de6f45e4 100644
--- a/module/VuFind/src/VuFind/View/Helper/Root/RecaptchaFactory.php
+++ b/module/VuFind/src/VuFind/Captcha/ReCaptchaFactory.php
@@ -1,10 +1,10 @@
 <?php
 /**
- * Recaptcha helper factory.
+ * Factory for ReCaptcha CAPTCHA module.
  *
  * PHP version 7
  *
- * Copyright (C) Villanova University 2018.
+ * Copyright (C) Villanova University 2020.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -20,26 +20,26 @@
  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
  *
  * @category VuFind
- * @package  View_Helpers
- * @author   Demian Katz <demian.katz@villanova.edu>
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development Wiki
  */
-namespace VuFind\View\Helper\Root;
+namespace VuFind\Captcha;
 
 use Interop\Container\ContainerInterface;
 use Laminas\ServiceManager\Factory\FactoryInterface;
 
 /**
- * Recaptcha helper factory.
+ * ReCaptcha CAPTCHA factory.
  *
  * @category VuFind
- * @package  View_Helpers
- * @author   Demian Katz <demian.katz@villanova.edu>
+ * @package  CAPTCHA
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development Wiki
  */
-class RecaptchaFactory implements FactoryInterface
+class ReCaptchaFactory implements FactoryInterface
 {
     /**
      * Create an object
@@ -59,11 +59,11 @@ class RecaptchaFactory implements FactoryInterface
         array $options = null
     ) {
         if (!empty($options)) {
-            throw new \Exception('Unexpected options sent to factory.');
+            throw new \Exception('Unexpected options passed to factory.');
         }
         return new $requestedName(
             $container->get(\VuFind\Service\ReCaptcha::class),
-            $container->get(\VuFind\Config\PluginManager::class)->get('config')
+            $container->get(\Laminas\Mvc\I18n\Translator::class)->getLocale()
         );
     }
 }
diff --git a/module/VuFind/src/VuFind/Config/Upgrade.php b/module/VuFind/src/VuFind/Config/Upgrade.php
index 58faf74a3962391627361931c859e90ce6e6e5b7..1fe1d0a753af903c34b906f6bda1fc5b3b2bfa33 100644
--- a/module/VuFind/src/VuFind/Config/Upgrade.php
+++ b/module/VuFind/src/VuFind/Config/Upgrade.php
@@ -585,6 +585,29 @@ class Upgrade
             }
         }
 
+        // Upgrade CAPTCHA Options
+        $legacySettingsMap = [
+            'publicKey' => 'recaptcha_siteKey',
+            'siteKey' => 'recaptcha_siteKey',
+            'privateKey' => 'recaptcha_secretKey',
+            'secretKey' => 'recaptcha_secretKey',
+            'theme' => 'recaptcha_theme',
+        ];
+        $foundRecaptcha = false;
+        foreach ($legacySettingsMap as $old => $new) {
+            if (isset($newConfig['Captcha'][$old])) {
+                $newConfig['Captcha'][$new]
+                    = $newConfig['Captcha'][$old];
+                unset($newConfig['Captcha'][$old]);
+            }
+            if (isset($newConfig['Captcha'][$new])) {
+                $foundRecaptcha = true;
+            }
+        }
+        if ($foundRecaptcha && !isset($newConfig['Captcha']['types'])) {
+            $newConfig['Captcha']['types'] = ['recaptcha'];
+        }
+
         // Warn the user about deprecated WorldCat settings:
         if (isset($newConfig['WorldCat']['LimitCodes'])) {
             unset($newConfig['WorldCat']['LimitCodes']);
diff --git a/module/VuFind/src/VuFind/Controller/AbstractBase.php b/module/VuFind/src/VuFind/Controller/AbstractBase.php
index a2897ab8d32bdb3b527756da6da4d8e1b87acbef..d8e1843407e879305acfd040d7dee1359ce6d1f4 100644
--- a/module/VuFind/src/VuFind/Controller/AbstractBase.php
+++ b/module/VuFind/src/VuFind/Controller/AbstractBase.php
@@ -544,17 +544,17 @@ class AbstractBase extends AbstractActionController
      * Also validate the Captcha, if it's activated
      *
      * @param string $submitElement Name of the post field of the submit button
-     * @param bool   $useRecaptcha  Are we using captcha in this situation?
+     * @param bool   $useCaptcha    Are we using captcha in this situation?
      *
      * @return bool
      */
     protected function formWasSubmitted($submitElement = 'submit',
-        $useRecaptcha = false
+        $useCaptcha = false
     ) {
         // Fail if the expected submission element was missing from the POST:
         // Form was submitted; if CAPTCHA is expected, validate it now.
         return $this->params()->fromPost($submitElement, false)
-            && (!$useRecaptcha || $this->recaptcha()->validate());
+            && (!$useCaptcha || $this->captcha()->verify());
     }
 
     /**
diff --git a/module/VuFind/src/VuFind/Controller/AbstractRecord.php b/module/VuFind/src/VuFind/Controller/AbstractRecord.php
index 2e5faef05448b169ceddb140af1da0856a0ecf29..2a4c454ed2254016a59a3dbd9430fb33b0ce8408 100644
--- a/module/VuFind/src/VuFind/Controller/AbstractRecord.php
+++ b/module/VuFind/src/VuFind/Controller/AbstractRecord.php
@@ -105,12 +105,12 @@ class AbstractRecord extends AbstractBase
             throw new ForbiddenException('Comments disabled');
         }
 
-        $recaptchaActive = $this->recaptcha()->active('userComments');
+        $captchaActive = $this->captcha()->active('userComments');
 
         // Force login:
         if (!($user = $this->getUser())) {
             // Validate CAPTCHA before redirecting to login:
-            if (!$this->formWasSubmitted('comment', $recaptchaActive)) {
+            if (!$this->formWasSubmitted('comment', $captchaActive)) {
                 return $this->redirectToRecord('', 'UserComments');
             }
 
@@ -129,7 +129,7 @@ class AbstractRecord extends AbstractBase
             $comment = $this->followup()->retrieveAndClear('comment');
         } else {
             // Validate CAPTCHA now only if we're not coming back post-login:
-            if (!$this->formWasSubmitted('comment', $recaptchaActive)) {
+            if (!$this->formWasSubmitted('comment', $captchaActive)) {
                 return $this->redirectToRecord('', 'UserComments');
             }
         }
@@ -446,10 +446,10 @@ class AbstractRecord extends AbstractBase
         );
         $mailer->setMaxRecipients($view->maxRecipients);
 
-        // Set up reCaptcha
-        $view->useRecaptcha = $this->recaptcha()->active('email');
+        // Set up Captcha
+        $view->useCaptcha = $this->captcha()->active('email');
         // Process form submission:
-        if ($this->formWasSubmitted('submit', $view->useRecaptcha)) {
+        if ($this->formWasSubmitted('submit', $view->useCaptcha)) {
             // Attempt to send the email and show an appropriate flash message:
             try {
                 $cc = $this->params()->fromPost('ccself') && $view->from != $view->to
@@ -502,10 +502,10 @@ class AbstractRecord extends AbstractBase
         $view = $this->createViewModel();
         $view->carriers = $sms->getCarriers();
         $view->validation = $sms->getValidationType();
-        // Set up reCaptcha
-        $view->useRecaptcha = $this->recaptcha()->active('sms');
+        // Set up Captcha
+        $view->useCaptcha = $this->captcha()->active('sms');
         // Process form submission:
-        if ($this->formWasSubmitted('submit', $view->useRecaptcha)) {
+        if ($this->formWasSubmitted('submit', $view->useCaptcha)) {
             // Send parameters back to view so form can be re-populated:
             $view->to = $this->params()->fromPost('to');
             $view->provider = $this->params()->fromPost('provider');
diff --git a/module/VuFind/src/VuFind/Controller/CartController.php b/module/VuFind/src/VuFind/Controller/CartController.php
index d018a3fb2fc75f7e37ddb7dbb2200802d7f7ca43..3eacd1744a47f67f32c5fe9f3a8282ccdf2b9cd3 100644
--- a/module/VuFind/src/VuFind/Controller/CartController.php
+++ b/module/VuFind/src/VuFind/Controller/CartController.php
@@ -255,11 +255,11 @@ class CartController extends AbstractBase
             null, $this->translate('bulk_email_title')
         );
         $view->records = $this->getRecordLoader()->loadBatch($ids);
-        // Set up reCaptcha
-        $view->useRecaptcha = $this->recaptcha()->active('email');
+        // Set up Captcha
+        $view->useCaptcha = $this->captcha()->active('email');
 
         // Process form submission:
-        if ($this->formWasSubmitted('submit', $view->useRecaptcha)) {
+        if ($this->formWasSubmitted('submit', $view->useCaptcha)) {
             // Build the URL to share:
             $params = [];
             foreach ($ids as $current) {
diff --git a/module/VuFind/src/VuFind/Controller/FeedbackController.php b/module/VuFind/src/VuFind/Controller/FeedbackController.php
index d939b509f0d3e5d249a6405f76ebb5ffc1976944..7fe73b9b042a8f2e4eff3ef37e2550d7b62f4a84 100644
--- a/module/VuFind/src/VuFind/Controller/FeedbackController.php
+++ b/module/VuFind/src/VuFind/Controller/FeedbackController.php
@@ -71,13 +71,13 @@ class FeedbackController extends AbstractBase
         }
 
         $view = $this->createViewModel(compact('form', 'formId', 'user'));
-        $view->useRecaptcha
-            = $this->recaptcha()->active('feedback') && $form->useCaptcha();
+        $view->useCaptcha
+            = $this->captcha()->active('feedback') && $form->useCaptcha();
 
         $params = $this->params();
         $form->setData($params->fromPost());
 
-        if (!$this->formWasSubmitted('submit', $view->useRecaptcha)) {
+        if (!$this->formWasSubmitted('submit', $view->useCaptcha)) {
             $form = $this->prefillUserInfo($form, $user);
             return $view;
         }
diff --git a/module/VuFind/src/VuFind/Controller/MyResearchController.php b/module/VuFind/src/VuFind/Controller/MyResearchController.php
index ac3ef7063fcf2e4d77b43c9e6e23a2e6ad1bbb41..0933d76e98b953d556866e3ea509392920846274 100644
--- a/module/VuFind/src/VuFind/Controller/MyResearchController.php
+++ b/module/VuFind/src/VuFind/Controller/MyResearchController.php
@@ -254,12 +254,12 @@ class MyResearchController extends AbstractBase
         // Password policy
         $view->passwordPolicy = $this->getAuthManager()
             ->getPasswordPolicy($method);
-        // Set up reCaptcha
-        $view->useRecaptcha = $this->recaptcha()->active('newAccount');
+        // Set up Captcha
+        $view->useCaptcha = $this->captcha()->active('newAccount');
         // Pass request to view so we can repopulate user parameters in form:
         $view->request = $this->getRequest()->getPost();
         // Process request, if necessary:
-        if ($this->formWasSubmitted('submit', $view->useRecaptcha)) {
+        if ($this->formWasSubmitted('submit', $view->useCaptcha)) {
             try {
                 $this->getAuthManager()->create($this->getRequest());
                 return $this->forwardTo('MyResearch', 'Home');
@@ -1547,9 +1547,9 @@ class MyResearchController extends AbstractBase
             $user = $table->getByUsername($username, false);
         }
         $view = $this->createViewModel();
-        $view->useRecaptcha = $this->recaptcha()->active('passwordRecovery');
+        $view->useCaptcha = $this->captcha()->active('passwordRecovery');
         // If we have a submitted form
-        if ($this->formWasSubmitted('submit', $view->useRecaptcha)) {
+        if ($this->formWasSubmitted('submit', $view->useCaptcha)) {
             if ($user) {
                 $this->sendRecoveryEmail($user, $this->getConfig());
             } else {
@@ -1759,8 +1759,8 @@ class MyResearchController extends AbstractBase
                         = $this->getAuthManager()->getAuthMethod();
                     $view->hash = $hash;
                     $view->username = $user->username;
-                    $view->useRecaptcha
-                        = $this->recaptcha()->active('changePassword');
+                    $view->useCaptcha
+                        = $this->captcha()->active('changePassword');
                     $view->setTemplate('myresearch/newpassword');
                     return $view;
                 }
@@ -1849,13 +1849,13 @@ class MyResearchController extends AbstractBase
         $userFromHash = isset($post->hash)
             ? $this->getTable('User')->getByVerifyHash($post->hash)
             : false;
-        // View, password policy and reCaptcha
+        // View, password policy and Captcha
         $view = $this->createViewModel($post);
         $view->passwordPolicy = $this->getAuthManager()
             ->getPasswordPolicy();
-        $view->useRecaptcha = $this->recaptcha()->active('changePassword');
-        // Check reCaptcha
-        if (!$this->formWasSubmitted('submit', $view->useRecaptcha)) {
+        $view->useCaptcha = $this->captcha()->active('changePassword');
+        // Check Captcha
+        if (!$this->formWasSubmitted('submit', $view->useCaptcha)) {
             $this->setUpAuthenticationFromRequest();
             return $this->resetNewPasswordForm($userFromHash, $view);
         }
@@ -1924,9 +1924,9 @@ class MyResearchController extends AbstractBase
         $user = $this->getUser();
         $view->email = $user->email;
         // Identification
-        $view->useRecaptcha = $this->recaptcha()->active('changeEmail');
+        $view->useCaptcha = $this->captcha()->active('changeEmail');
         // Special case: form was submitted:
-        if ($this->formWasSubmitted('submit', $view->useRecaptcha)) {
+        if ($this->formWasSubmitted('submit', $view->useCaptcha)) {
             // Do CSRF check
             $csrf = $this->serviceLocator->get(\VuFind\Validator\Csrf::class);
             if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
@@ -2005,7 +2005,7 @@ class MyResearchController extends AbstractBase
         $user->updateHash();
         $view->hash = $user->verify_hash;
         $view->setTemplate('myresearch/newpassword');
-        $view->useRecaptcha = $this->recaptcha()->active('changePassword');
+        $view->useCaptcha = $this->captcha()->active('changePassword');
         return $view;
     }
 
diff --git a/module/VuFind/src/VuFind/Controller/Plugin/Recaptcha.php b/module/VuFind/src/VuFind/Controller/Plugin/Captcha.php
similarity index 67%
rename from module/VuFind/src/VuFind/Controller/Plugin/Recaptcha.php
rename to module/VuFind/src/VuFind/Controller/Plugin/Captcha.php
index 60e49528765e672c3be26af760ea9e0cd1fb73e8..ed14750883e5bb7d16b5185a711e2f659639fc66 100644
--- a/module/VuFind/src/VuFind/Controller/Plugin/Recaptcha.php
+++ b/module/VuFind/src/VuFind/Controller/Plugin/Captcha.php
@@ -1,10 +1,10 @@
 <?php
 /**
- * VuFind Action Helper - Recaptcha handler
+ * VuFind Action Helper - Captcha handler
  *
  * PHP version 7
  *
- * Copyright (C) Villanova University 2010.
+ * Copyright (C) Villanova University 2020.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -22,6 +22,7 @@
  * @category VuFind
  * @package  Controller_Plugins
  * @author   Chris Hallberg <crhallberg@gmail.com>
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org Main Page
  */
@@ -30,23 +31,26 @@ namespace VuFind\Controller\Plugin;
 use Laminas\Mvc\Controller\Plugin\AbstractPlugin;
 
 /**
- * Action helper to manage Recaptcha fields
+ * Action helper to manage Captcha fields
  *
  * @category VuFind
  * @package  Controller_Plugins
  * @author   Chris Hallberg <crhallberg@gmail.com>
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org Main Page
  */
-class Recaptcha extends AbstractPlugin
+class Captcha extends AbstractPlugin
 {
     /**
-     * \Laminas\ReCaptcha\ReCaptcha
+     * Captcha services
+     *
+     * @var array
      */
-    protected $recaptcha;
+    protected $captchas = [];
 
     /**
-     * String array of forms where ReCaptcha is active
+     * String array of forms where Captcha is active
      */
     protected $domains = [];
 
@@ -63,15 +67,15 @@ class Recaptcha extends AbstractPlugin
     /**
      * Constructor
      *
-     * @param \Laminas\ReCaptcha\ReCaptcha $r      Customed reCAPTCHA object
-     * @param \VuFind\Config               $config Config file
+     * @param \VuFind\Config $config   Config file
+     * @param array          $captchas CAPTCHA objects
      *
      * @return void
      */
-    public function __construct($r, $config)
+    public function __construct($config, array $captchas=[])
     {
-        $this->recaptcha = $r;
-        if (isset($config->Captcha->forms)) {
+        $this->captchas = $captchas;
+        if (count($captchas) > 0 && isset($config->Captcha->forms)) {
             $this->active = true;
             $this->domains = '*' == trim($config->Captcha->forms)
                 ? true
@@ -89,7 +93,7 @@ class Recaptcha extends AbstractPlugin
      *
      * @return bool
      */
-    public function setErrorMode($mode)
+    public function setErrorMode($mode): bool
     {
         if (in_array($mode, ['flash', 'throw', 'none'])) {
             $this->errorMode = $mode;
@@ -99,39 +103,36 @@ class Recaptcha extends AbstractPlugin
     }
 
     /**
-     * Return the raw service object
-     *
-     * @return VuFind\Service\Recaptcha
-     */
-    public function getObject()
-    {
-        return $this->recaptcha;
-    }
-
-    /**
-     * Pull the captcha field from POST and check them for accuracy
+     * Pull the captcha field from controller params and check them for accuracy
      *
      * @return bool
      */
-    public function validate()
+    public function verify(): bool
     {
         if (!$this->active()) {
             return true;
         }
-        $responseField = $this->getController()->params()
-            ->fromPost('g-recaptcha-response');
-        try {
-            $response = $this->recaptcha->verify($responseField);
-        } catch (\Laminas\ReCaptcha\Exception $e) {
-            $response = false;
+        $captchaPassed = false;
+        foreach ($this->captchas as $captcha) {
+            try {
+                $captchaPassed = $captcha->verify(
+                    $this->getController()->params()
+                );
+            } catch (\Exception $e) {
+                $captchaPassed = false;
+            }
+
+            if ($captchaPassed) {
+                break;
+            }
         }
-        $captchaPassed = $response && $response->isValid();
+
         if (!$captchaPassed && $this->errorMode != 'none') {
             if ($this->errorMode == 'flash') {
                 $this->getController()->flashMessenger()
-                    ->addMessage('recaptcha_not_passed', 'error');
+                    ->addMessage('captcha_not_passed', 'error');
             } else {
-                throw new \Exception('recaptcha_not_passed');
+                throw new \Exception('captcha_not_passed');
             }
         }
         return $captchaPassed;
@@ -144,7 +145,7 @@ class Recaptcha extends AbstractPlugin
      *
      * @return bool
      */
-    public function active($domain = false)
+    public function active($domain = false): bool
     {
         return $this->active
         && ($domain == false || $this->domains === true
diff --git a/module/VuFind/src/VuFind/Controller/Plugin/RecaptchaFactory.php b/module/VuFind/src/VuFind/Controller/Plugin/CaptchaFactory.php
similarity index 75%
rename from module/VuFind/src/VuFind/Controller/Plugin/RecaptchaFactory.php
rename to module/VuFind/src/VuFind/Controller/Plugin/CaptchaFactory.php
index ef3d3f61be756d3d771d01009977cde368deff07..0fbe67773162f1a07795bd1372aec3e3b7ec19f7 100644
--- a/module/VuFind/src/VuFind/Controller/Plugin/RecaptchaFactory.php
+++ b/module/VuFind/src/VuFind/Controller/Plugin/CaptchaFactory.php
@@ -1,10 +1,10 @@
 <?php
 /**
- * Factory for Recaptcha controller plugin.
+ * Factory for Captcha controller plugin.
  *
  * PHP version 7
  *
- * Copyright (C) Villanova University 2019.
+ * Copyright (C) Villanova University 2020.
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License version 2,
@@ -22,6 +22,7 @@
  * @category VuFind
  * @package  Controller_Plugins
  * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org Main Page
  */
@@ -31,15 +32,16 @@ use Interop\Container\ContainerInterface;
 use Laminas\ServiceManager\Factory\FactoryInterface;
 
 /**
- * Factory for Recaptcha controller plugin.
+ * Factory for Captcha controller plugin.
  *
  * @category VuFind
  * @package  Controller_Plugins
  * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
  * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
  * @link     https://vufind.org/wiki/development:plugins:recommendation_modules Wiki
  */
-class RecaptchaFactory implements FactoryInterface
+class CaptchaFactory implements FactoryInterface
 {
     /**
      * Create an object
@@ -61,9 +63,21 @@ class RecaptchaFactory implements FactoryInterface
         if (!empty($options)) {
             throw new \Exception('Unexpected options sent to factory.');
         }
+
+        $config = $container->get(\VuFind\Config\PluginManager::class)
+            ->get('config');
+
+        $captchaTypes = $config->Captcha->types ?? [];
+
+        $captchas = [];
+        foreach ($captchaTypes as $captchaType) {
+            $captchas[] = $container->get(\VuFind\Captcha\PluginManager::class)
+                ->get(trim($captchaType));
+        }
+
         return new $requestedName(
-            $container->get(\VuFind\Service\ReCaptcha::class),
-            $container->get(\VuFind\Config\PluginManager::class)->get('config')
+            $config,
+            $captchas
         );
     }
 }
diff --git a/module/VuFind/src/VuFind/Controller/SearchController.php b/module/VuFind/src/VuFind/Controller/SearchController.php
index cfb09f7c3b5dcb20b570ecd056f5f05ef8a3e6d0..81ffdddd6e9c129d58869a981d064d14b1af2495 100644
--- a/module/VuFind/src/VuFind/Controller/SearchController.php
+++ b/module/VuFind/src/VuFind/Controller/SearchController.php
@@ -117,8 +117,8 @@ class SearchController extends AbstractSolrSearch
         $mailer = $this->serviceLocator->get(\VuFind\Mailer\Mailer::class);
         $view = $this->createEmailViewModel(null, $mailer->getDefaultLinkSubject());
         $mailer->setMaxRecipients($view->maxRecipients);
-        // Set up reCaptcha
-        $view->useRecaptcha = $this->recaptcha()->active('email');
+        // Set up Captcha
+        $view->useCaptcha = $this->captcha()->active('email');
         $view->url = $this->params()->fromPost(
             'url', $this->params()->fromQuery(
                 'url',
@@ -147,7 +147,7 @@ class SearchController extends AbstractSolrSearch
         }
 
         // Process form submission:
-        if ($this->formWasSubmitted('submit', $view->useRecaptcha)) {
+        if ($this->formWasSubmitted('submit', $view->useCaptcha)) {
             // Attempt to send the email and show an appropriate flash message:
             try {
                 // If we got this far, we're ready to send the email:
diff --git a/module/VuFind/src/VuFind/RecordTab/UserComments.php b/module/VuFind/src/VuFind/RecordTab/UserComments.php
index 209936953f24272d492474dadec0edf51ac611fd..90bf8a3e09745f0e606b674102680fbf3e96d9e0 100644
--- a/module/VuFind/src/VuFind/RecordTab/UserComments.php
+++ b/module/VuFind/src/VuFind/RecordTab/UserComments.php
@@ -46,32 +46,32 @@ class UserComments extends AbstractBase
     protected $enabled;
 
     /**
-     * Should we use ReCaptcha?
+     * Should we use Captcha?
      *
      * @var bool
      */
-    protected $useRecaptcha;
+    protected $useCaptcha;
 
     /**
      * Constructor
      *
      * @param bool $enabled is this tab enabled?
-     * @param bool $urc     use recaptcha?
+     * @param bool $uc      use captcha?
      */
-    public function __construct($enabled = true, $urc = false)
+    public function __construct($enabled = true, $uc = false)
     {
         $this->enabled = $enabled;
-        $this->useRecaptcha = $urc;
+        $this->useCaptcha = $uc;
     }
 
     /**
-     * Is Recaptcha active?
+     * Is Captcha active?
      *
      * @return bool
      */
-    public function isRecaptchaActive()
+    public function isCaptchaActive()
     {
-        return $this->useRecaptcha;
+        return $this->useCaptcha;
     }
 
     /**
diff --git a/module/VuFind/src/VuFind/RecordTab/UserCommentsFactory.php b/module/VuFind/src/VuFind/RecordTab/UserCommentsFactory.php
index 4d27472fd17077adc03aeb5891f3c46ac42bfdc1..89e2b946cb83bd3e1bcdbe5c59a0106cdc88f987 100644
--- a/module/VuFind/src/VuFind/RecordTab/UserCommentsFactory.php
+++ b/module/VuFind/src/VuFind/RecordTab/UserCommentsFactory.php
@@ -66,11 +66,11 @@ class UserCommentsFactory implements \Laminas\ServiceManager\Factory\FactoryInte
         $config = $container->get(\VuFind\Config\PluginManager::class)
             ->get('config');
         $captchaConfig = $config->Captcha->forms ?? '';
-        $useRecaptcha = trim($captchaConfig) === '*'
+        $useCaptcha = trim($captchaConfig) === '*'
             || strpos($captchaConfig, 'userComments') !== false;
         return new $requestedName(
             'enabled' === $capabilities->getCommentSetting(),
-            $useRecaptcha
+            $useCaptcha
         );
     }
 }
diff --git a/module/VuFind/src/VuFind/Service/ReCaptchaFactory.php b/module/VuFind/src/VuFind/Service/ReCaptchaFactory.php
index ecd38f430deb5579da83f2908e060f29ca728315..d1d4defe22c916895f9d0bea7572c45830e25a42 100644
--- a/module/VuFind/src/VuFind/Service/ReCaptchaFactory.php
+++ b/module/VuFind/src/VuFind/Service/ReCaptchaFactory.php
@@ -61,24 +61,39 @@ class ReCaptchaFactory implements FactoryInterface
         if (!empty($options)) {
             throw new \Exception('Unexpected options passed to factory.');
         }
+
         $config = $container->get(\VuFind\Config\PluginManager::class)
             ->get('config');
-        $siteKey = isset($config->Captcha->siteKey)
-            ? $config->Captcha->siteKey
-            : (isset($config->Captcha->publicKey)
-                ? $config->Captcha->publicKey
-                : '');
-        $secretKey = isset($config->Captcha->secretKey)
-            ? $config->Captcha->secretKey
-            : (isset($config->Captcha->privateKey)
-                ? $config->Captcha->privateKey
-                : '');
+
+        $legacySettingsMap = [
+            'publicKey' => 'recaptcha_siteKey',
+            'siteKey' => 'recaptcha_siteKey',
+            'privateKey' => 'recaptcha_secretKey',
+            'secretKey' => 'recaptcha_secretKey',
+            'theme' => 'recaptcha_theme',
+        ];
+
+        $recaptchaConfig = $config->Captcha->toArray();
+        foreach ($legacySettingsMap as $old => $new) {
+            if (isset($recaptchaConfig[$old])) {
+                error_log(
+                    'Deprecated ' . $old . ' setting found in config.ini - '
+                    . 'please use ' . $new . ' instead.'
+                );
+                if (!isset($recaptchaConfig[$new])) {
+                    $recaptchaConfig[$new] = $recaptchaConfig[$old];
+                }
+            }
+        }
+
+        $siteKey = $recaptchaConfig['recaptcha_siteKey'] ?? '';
+        $secretKey = $recaptchaConfig['recaptcha_secretKey'] ?? '';
         $httpClient = $container->get(\VuFindHttp\HttpService::class)
             ->createClient();
         $translator = $container->get(\Laminas\Mvc\I18n\Translator::class);
         $rcOptions = ['lang' => $translator->getLocale()];
-        if (isset($config->Captcha->theme)) {
-            $rcOptions['theme'] = $config->Captcha->theme;
+        if (isset($recaptchaConfig['recaptcha_theme'])) {
+            $rcOptions['theme'] = $recaptchaConfig['recaptcha_theme'];
         }
         return new $requestedName(
             $siteKey, $secretKey, ['ssl' => true], $rcOptions, null, $httpClient
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/Captcha.php b/module/VuFind/src/VuFind/View/Helper/Root/Captcha.php
new file mode 100644
index 0000000000000000000000000000000000000000..818328b23579be7f596a447286a3a82cc663e696
--- /dev/null
+++ b/module/VuFind/src/VuFind/View/Helper/Root/Captcha.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Captcha view helper
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Chris Hallberg <crhallberg@gmail.com>
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\View\Helper\Root;
+
+/**
+ * Captcha view helper
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Chris Hallberg <crhallberg@gmail.com>
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class Captcha extends AbstractClassBasedTemplateRenderer
+{
+    /**
+     * Captcha services
+     *
+     * @var array
+     */
+    protected $captchas = [];
+
+    /**
+     * Config
+     *
+     * @var \Laminas\Config\Config
+     */
+    protected $config;
+
+    /**
+     * Constructor
+     *
+     * @param \Laminas\Config\Config $config   Config
+     * @param array                  $captchas Captchas
+     */
+    public function __construct(\Laminas\Config\Config $config,
+        array $captchas=[]
+    ) {
+        $this->config = $config;
+        $this->captchas = $captchas;
+    }
+
+    /**
+     * Return this object
+     *
+     * @return \VuFind\View\Helper\Root\Captcha
+     */
+    public function __invoke(): \VuFind\View\Helper\Root\Captcha
+    {
+        return $this;
+    }
+
+    /**
+     * Generate HTML of a single CAPTCHA (redirect to template)
+     *
+     * @param \VuFind\Captcha\AbstractBase $captcha Captcha
+     *
+     * @return string
+     */
+    public function getHtmlForCaptcha(\VuFind\Captcha\AbstractBase $captcha): string
+    {
+        return $this->renderClassTemplate(
+            'Captcha/%s',
+            strtolower(get_class($captcha)),
+            ['captcha' => $captcha]
+        );
+    }
+
+    /**
+     * Generate HTML depending on CAPTCHA type (empty if not active).
+     *
+     * @param bool $useCaptcha Boolean of active state, for compact templating
+     * @param bool $wrapHtml   Wrap in a form-group?
+     *
+     * @return string
+     */
+    public function html(bool $useCaptcha = true, bool $wrapHtml = true): string
+    {
+        if (count($this->captchas) == 0 || !$useCaptcha) {
+            return '';
+        }
+
+        return $this->getView()->render(
+            'Helpers/captcha', ['wrapHtml' => $wrapHtml,
+                                'captchas' => $this->captchas]
+        );
+    }
+
+    /**
+     * Get list of URLs with JS dependancies to load for the active CAPTCHA type.
+     *
+     * @return array
+     */
+    public function js(): array
+    {
+        $jsIncludes = [];
+        foreach ($this->captchas as $captcha) {
+            $jsIncludes = array_merge($jsIncludes, $captcha->getJsIncludes());
+        }
+        return array_unique($jsIncludes);
+    }
+
+    /**
+     * Return whether Captcha is active in the config
+     *
+     * @return bool
+     */
+    protected function active(): bool
+    {
+        return count($this->captchas) > 0
+            && isset($this->config->Captcha->forms);
+    }
+}
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/CaptchaFactory.php b/module/VuFind/src/VuFind/View/Helper/Root/CaptchaFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..04d6f971cefb4b62b0f25e7d582f172f43369b37
--- /dev/null
+++ b/module/VuFind/src/VuFind/View/Helper/Root/CaptchaFactory.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Captcha helper factory.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\View\Helper\Root;
+
+use Interop\Container\ContainerInterface;
+use Laminas\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * Captcha helper factory.
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Mario Trojan <mario.trojan@uni-tuebingen.de>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class CaptchaFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options sent to factory.');
+        }
+
+        $config = $container->get(\VuFind\Config\PluginManager::class)
+            ->get('config');
+
+        $captchaTypes = $config->Captcha->types ?? [];
+
+        $captchas = [];
+        foreach ($captchaTypes as $captchaType) {
+            $captchas[] = $container->get(\VuFind\Captcha\PluginManager::class)
+                ->get(trim($captchaType));
+        }
+
+        return new $requestedName(
+            $config,
+            $captchas
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/Recaptcha.php b/module/VuFind/src/VuFind/View/Helper/Root/Recaptcha.php
deleted file mode 100644
index 764f13377574ab249565639d259134c090623e8a..0000000000000000000000000000000000000000
--- a/module/VuFind/src/VuFind/View/Helper/Root/Recaptcha.php
+++ /dev/null
@@ -1,121 +0,0 @@
-<?php
-/**
- * Recaptcha view helper
- *
- * PHP version 7
- *
- * Copyright (C) Villanova University 2010.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License version 2,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
- *
- * @category VuFind
- * @package  View_Helpers
- * @author   Chris Hallberg <crhallberg@gmail.com>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development Wiki
- */
-namespace VuFind\View\Helper\Root;
-
-use Laminas\View\Helper\AbstractHelper;
-
-/**
- * Recaptcha view helper
- *
- * @category VuFind
- * @package  View_Helpers
- * @author   Chris Hallberg <crhallberg@gmail.com>
- * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
- * @link     https://vufind.org/wiki/development Wiki
- */
-class Recaptcha extends AbstractHelper
-{
-    /**
-     * Recaptcha controller helper
-     *
-     * @var Recaptcha
-     */
-    protected $recaptcha;
-
-    /**
-     * Recaptcha config
-     *
-     * @var Config
-     */
-    protected $active;
-
-    /**
-     * HTML prefix for ReCaptcha output.
-     *
-     * @var string
-     */
-    protected $prefixHtml = '';
-
-    /**
-     * HTML suffix for ReCaptcha output.
-     *
-     * @var string
-     */
-    protected $suffixHtml = '';
-
-    /**
-     * Constructor
-     *
-     * @param \Laminas\Recaptcha\Recaptcha $rc     Custom formatted Recaptcha
-     * @param \VuFind\Config               $config Config object
-     */
-    public function __construct($rc, $config)
-    {
-        $this->recaptcha = $rc;
-        $this->active = isset($config->Captcha->forms);
-    }
-
-    /**
-     * Return this object
-     *
-     * @return VuFind\View\Helper\Root\Recaptcha
-     */
-    public function __invoke()
-    {
-        return $this;
-    }
-
-    /**
-     * Generate <div> with ReCaptcha from render.
-     *
-     * @param bool $useRecaptcha Boolean of active state, for compact templating
-     * @param bool $wrapHtml     Include prefix and suffix?
-     *
-     * @return string $html
-     */
-    public function html($useRecaptcha = true, $wrapHtml = true)
-    {
-        if (!isset($useRecaptcha) || !$useRecaptcha) {
-            return false;
-        }
-        if (!$wrapHtml) {
-            return $this->recaptcha->getHtml();
-        }
-        return $this->prefixHtml . $this->recaptcha->getHtml() . $this->suffixHtml;
-    }
-
-    /**
-     * Return whether Captcha is active in the config
-     *
-     * @return bool
-     */
-    public function active()
-    {
-        return $this->active;
-    }
-}
diff --git a/module/VuFind/tests/fixtures/configs/recaptcha/config.ini b/module/VuFind/tests/fixtures/configs/recaptcha/config.ini
new file mode 100644
index 0000000000000000000000000000000000000000..7bae0b63deeddb22468266afd6e9d70e9e817386
--- /dev/null
+++ b/module/VuFind/tests/fixtures/configs/recaptcha/config.ini
@@ -0,0 +1,1733 @@
+;
+; VuFind Configuration
+;
+
+; This section controls global system behavior and can usually be left unmodified.
+[System]
+; Change to false to take the system offline and show an unavailability message;
+; note that you can use the NoILS driver (in [Catalog] section below) to keep VuFind
+; up during ILS maintenance.
+available       = true
+; Change to true to see messages about the behavior of the system as part of the
+; output -- only for use when troubleshooting problems. See also the access.DebugMode
+; setting in permissions.ini to turn on debug using a GET parameter in the request.
+debug           = false
+; This setting should be set to false after auto-configuration is complete
+autoConfigure = true
+; This setting specifies a health check file location. If a health check file exists,
+; the getServerStatus AJAX call will return an error regardless of actual status
+; allowing the server to be disabled from a load-balancer.
+;healthCheckFile = /tmp/disable_vufind
+
+; This section will need to be customized for your installation
+[Site]
+; Base URL is normally auto-detected, but this setting is used when autodetection is
+; not possible (i.e. during sitemap generation at the command line).
+url             = http://library.myuniversity.edu/vufind
+; Set to true if VuFind is behind a reverse proxy (typically Apache with mod_proxy),
+; make sure your reverse proxy sets the necessary headers.
+;reverse_proxy    = true
+email           = support@myuniversity.edu
+title           = "Library Catalog"
+; This is the default theme for non-mobile devices (or all devices if mobile_theme
+; is disabled below). Available standard themes:
+;   bootstrap3 = HTML5 theme using Bootstrap 3 + jQuery libraries, with minimal styling
+;   bootprint3 = bootstrap3 theme with more attractive default styling applied
+;                (named after the earlier, now-deprecated blueprint theme)
+;   sandal     = bootstrap3 theme with a "flat" styling applied (a newer look
+;                than bootprint3).
+theme           = bootprint3
+
+; Uncomment the following line to use a different default theme for mobile devices.
+; You may not wish to use this setting if you are using one of the Bootstrap-based
+; standard themes since they support responsive design.
+;mobile_theme    = mobile
+
+; Automatic asset minification and concatenation setting. When active, HeadScript
+; and HeadLink will concatenate and minify all viable files to reduce requests and
+; load times. This setting is off by default.
+;
+; This configuration takes the form of a semi-colon separated list of
+; environment:configuration pairs where "environment" is a possible APPLICATION_ENV
+; value (e.g. 'production' or 'development') or '*'/no prefix for all contexts.
+; Possible values for 'configuration' within each environment are 'js', 'css',
+; 'off'/false, 'on'/true/'*'. This allows global enabling/disabling of the pipeline
+; or separate configurations for different types of resources. Multiple configuration
+; values may be comma-separated -- e.g. 'js,css'.
+;
+; Example: "development:off; production:js,css"
+;asset_pipeline = "production:js"
+
+; This is a comma-separated list of themes that may be accessed via the ?ui GET
+; parameter.  Each entry has two parts: the value used on the URL followed by the
+; actual theme name.  For example, http://library.myuniversity.edu/vufind?ui=theme1
+; would load the myTheme1 theme with the setting shown below.  Note that the values
+; of "standard" and "mobile" are reserved for the default and mobile themes defined
+; above.
+;alternate_themes = theme1:myTheme1,theme2:myTheme2
+
+; This is a comma-separated list of theme options that will be displayed to the user
+; as a drop-down.  Each entry has two parts: a value for the "ui" GET parameter and
+; an on-screen description.  "standard" refers to the "theme" setting above, "mobile"
+; refers to the "mobile_theme" setting, and all other values must be defined in
+; alternate_themes above.  When commented out, no drop-down theme list will display.
+;selectable_themes = "standard:Standard Theme,mobile:Mobile Theme"
+
+; Use the browser language setting to set the VuFind language.
+browserDetectLanguage = true
+language        = en    ; default -- more options available in [Languages] below.
+locale          = en_US
+; Set this to specify a default ISO 4217 currency code (used on the fines screen).
+; If omitted, the default currency for the locale above will be used.
+;defaultCurrency = USD
+; Find valid timezone values here:
+;   http://www.php.net/manual/en/timezones.php
+timezone        = "America/New_York"
+; A string used to format user interface date strings using the PHP date() function
+; default is m-d-Y (MM-DD-YYYY 01-01-2010)
+displayDateFormat = "m-d-Y"
+; A string used to format user interface time strings using the PHP date() function
+; default is H:i (HH:MM 23:01)
+displayTimeFormat = "H:i"
+; The base VuFind URL will load this controller unless the user is logged in:
+defaultModule   = Search
+; When defaultModule is used, this action will be triggered (default = Home)
+;defaultAction = Home
+; The base VuFind URL will load this controller when the user is logged in:
+defaultLoggedInModule = MyResearch
+; When defaultLoggedInModule is used, this action will be triggered (default = Home)
+;defaultLoggedInAction = Home
+; The search backend that VuFind will use in search boxes when nothing else is
+; specified (e.g. on user account pages, search history, etc.). Default = Solr
+;defaultSearchBackend = Solr
+; The route VuFind will send users to following a log out operation. Set to false
+; or omit to attempt to retain the user's current context after log out.
+;logOutRoute = home
+; Default tab to display when a record is viewed (see also RecordTabs.ini):
+defaultRecordTab = Holdings
+; Hide the holdings tab if no holdings are available from the ILS; note that this
+; feature requires your ILS driver to support the hasHoldings() method.
+hideHoldingsTabWhenEmpty = false
+; Whether to load the default tab through AJAX (which brings some performance
+; gain but breaks compatibility with non-Javascript-enabled browsers; off by default)
+;loadInitialTabWithAjax = true
+; The holdingsTemplate to use to display the ILS holdings (defaults to standard).
+; See the templates/RecordTab/holdingsils subdirectory of your theme for options.
+;holdingsTemplate = extended
+; This page will show by default when a user accesses the MyResearch module:
+defaultAccountPage = Favorites
+; Allow access to the Admin module? (See the access.AdminModule setting in
+; permissions.ini for more granular ways to restrict Admin access).
+admin_enabled = false
+; Show sidebar on the left side instead of right
+sidebarOnLeft = false
+; Invert the sidebarOnLeft setting for right-to-left languages?
+mirrorSidebarInRTL = true
+; Put search result thumbnails on the left (true) or right (false)
+resultThumbnailsOnLeft = true
+; Put favorites list thumbnails on the left (true) or right (false)
+listThumbnailsOnLeft = true
+; Put hold/checkedout/ILL/etc. item thumbnails on the left (true) or right (false)
+accountThumbnailsOnLeft = true
+; Show thumbnail on opposite side in right-to-left languages?
+mirrorThumbnailsRTL = true
+; Handle menu as an offcanvas slider at mobile sizes (in bootstrap3-based themes)
+offcanvas = false
+; Show (true) / Hide (false) Book Bag - Default is Hide.
+showBookBag = false
+; Set the maximum amount of items allowed in the Book Bag - Default is 100
+bookBagMaxSize = 100
+; Show individual add/remove bookbag buttons in search results? (Supersedes cart
+; checkboxes and bulk action buttons unless showBulkOptions is true).
+bookbagTogglesInSearch = true
+; Display bulk items (export, save, etc.) and checkboxes on search result screens?
+showBulkOptions = false
+; Should users be allowed to save searches in their accounts?
+allowSavedSearches = true
+; Some VuFind features can be made compatible with non-Javascript browsers at
+; a performance cost. By default, this compatibility is disabled, but it can
+; be turned on here. Note that even with this setting turned on, some features
+; still require Javascript; this simply improves compatibility for certain
+; features (such as display of hierarchies).
+nonJavascriptSupportEnabled = false
+; Generator value to display in an HTML header <meta> tag:
+generator = "VuFind 6.1.1"
+
+; This section allows you to configure the mechanism used for storing user
+; sessions.  Available types: File, Memcache, Database, Redis.
+; Some of the settings below only apply to specific session handlers;
+; such settings are named with an obvious prefix.  Non-prefixed settings
+; are global to all handlers.
+[Session]
+type                        = File
+lifetime                    = 3600 ; Session lasts for 1 hour
+; Should stored session data be encrypted?
+secure = false
+; Keep-alive interval in seconds. When set to a positive value, the session is kept
+; alive with a JavaScript call as long as a VuFind page is open in the browser.
+; Default is 0 (disabled). When keep-alive is enabled, session lifetime above can be
+; reduced to e.g. 600.
+;keepAlive = 60
+;file_save_path              = /tmp/vufind_sessions
+;memcache_host               = localhost
+;memcache_port               = 11211
+;memcache_connection_timeout = 1
+;
+; Settings related to Redis-based sessions; default values are listed below
+;redis_host               = localhost
+;redis_port               = 6379
+;redis_connection_timeout = 0.5
+;redis_db                 = 0
+;redis_auth               = some_secret_password
+;redis_version            = 3
+;redis_standalone         = true
+
+; This section controls how VuFind creates cookies (to store session IDs, bookbag
+; contents, theme/language settings, etc.)
+[Cookies]
+; In case there are multiple VuFind instances on the same server and they should not
+; share cookies/sessions, this option can be enabled to limit the session to the
+; current path. Default is false, which will place cookies at the root directory.
+;limit_by_path = true
+; If VuFind is only accessed via HTTPS, this setting can be enabled to disallow
+; the browser from ever sending cookies over an unencrypted connection (i.e.
+; before being redirected to HTTPS). Default is false.
+;only_secure = true
+; Whether to set cookies set by the server (apart from cart function) "HTTP only" so
+; that they cannot be accessed by scripts. Default is true.
+;http_only = false
+; Set the domain used for cookies (sometimes useful for sharing the cookies across
+; subdomains); by default, cookies will be restricted to the current hostname.
+;domain = ".example.edu"
+; This sets the session cookie's name. Comment this out to use the default
+; PHP_SESS_ID value. If running multiple versions of VuFind (or multiple PHP
+; applications) on the same host, it is strongly recommended to give each a
+; different session_name setting to avoid data contamination.
+session_name = VUFIND_SESSION
+
+; Please set the ILS that VuFind will interact with.
+;
+; Available drivers:
+;   - Aleph
+;   - Alma
+;   - Amicus
+;   - DAIA (using either XML or JSON API)
+;   - Demo (fake ILS driver returning complex responses)
+;   - Evergreen
+;   - Folio
+;   - Horizon (basic database access only)
+;   - HorizonXMLAPI (more features via API)
+;   - Innovative (for INNOPAC; see also Sierra/SierraRest)
+;   - Koha (basic database access only)
+;   - KohaILSDI (more features via ILS-DI API)
+;   - LBS4
+;   - MultiBackend (to chain together multiple drivers in a consortial setting)
+;   - NewGenLib
+;   - NoILS (for users with no ILS, or to disable ILS features during maintenance),
+;   - PAIA
+;   - Polaris
+;   - Sample (fake ILS driver returning bare-minimum data)
+;   - Sierra (basic database access only)
+;   - SierraRest (more features via API)
+;   - Symphony (uses native SirsiDynix APIs)
+;   - Unicorn (also applies to Symphony; requires installation of connector found at:
+;     http://code.google.com/p/vufind-unicorn/)
+;   - Virtua
+;   - Voyager (database access only; for Voyager 6+)
+;   - VoyagerRestful (for Voyager 7+ w/ RESTful web services)
+;   - XCNCIP2 (for XC NCIP Tookit v2.x)
+;
+; If you haven't set up your ILS yet, two fake drivers are available for testing
+; purposes. "Sample" is fast but does very little; "Demo" simulates more
+; functionality of a real ILS but may slow down your system by performing extra
+; searches. If you don't plan to use an ILS, the NoILS driver is your best option.
+;
+; Note: Enabling most of the features in this section will only work if you use an
+; ILS driver that supports them; not all drivers support holds/renewals.
+[Catalog]
+driver          = Sample
+
+; loadNoILSOnFailure - Whether or not to load the NoILS driver if the main driver fails
+loadNoILSOnFailure = false
+
+; List of search backends that contain records from your ILS (defaults to Solr
+; unless set otherwise). You can set ilsBackends = false to disable ILS status
+; loading entirely.
+;ilsBackends[] = Solr
+
+; This setting determines how and when hold / recall links are displayed.
+; Legal values:
+; - all (Show links for all items - Place Hold for Available Items and Place Recall
+;   for unavailable items)
+; - availability (Only show recall links if ALL items on bib are currently
+;   unavailable)
+; - disabled (Never show hold/recall links)
+; - driver (Use ILS driver to determine which items may be held/recalled; best option
+;   if available, but not supported by all drivers)
+; - holds (Only show links for available items)
+; - recalls (Only show links for unavailable items)
+; default is "all"
+holds_mode = "all"
+
+; Set this to true if you want to allow your ILS driver to override your holds_mode
+; setting on a record-by-record basis; this may be useful for local customizations,
+; but in most cases you should leave this setting unchanged.  Overrides are ignored
+; for mode settings of "driver" or "disabled."
+allow_holds_override = false
+
+; Determines if holds can be cancelled or not. Options are true or false.
+; default is false
+cancel_holds_enabled = false
+
+; Determines if storage retrieval requests can be cancelled or not.
+; Options are true or false.
+; default is false
+cancel_storage_retrieval_requests_enabled = false
+
+; Determines if ILL requests can be cancelled or not.
+; Options are true or false.
+; default is false
+cancel_ill_requests_enabled = false
+
+; Determines if item can be renewed or not. Options are true or false.
+; default is false
+renewals_enabled = false
+
+; Determines if title level holds are displayed or not.
+; Legal values:
+; - disabled (Never show title Holds - Default)
+; - always (Always show title Holds)
+; - availability (Only show title holds if ALL items on bib are currently
+;   unavailable)
+; - driver (Use ILS driver to determine which items may be held/recalled; best option
+;   if available, but not supported by all drivers)
+title_level_holds_mode = "disabled"
+
+; Determines how holdings are grouped in the record display, using fields from
+; the item information provided by the ILS driver.
+;
+; Most commonly-used values:
+; - holdings_id,location (Use holdings record id if available, location name as
+;   secondary - Default)
+; - location (Use location name)
+;
+; See https://vufind.org/wiki/development:plugins:ils_drivers#getholding for
+; more options (though not every ILS driver supports every possible value).
+;
+; Note that there may also be driver-specific values outside of the specification,
+; such as:
+; - item_agency_id (XCNCIP2 driver's Agency ID, which may be useful in consortial
+;   environments)
+;
+; You may use multiple group keys (delimited by comma), e.g.,
+; - item_agency_id,location
+;holdings_grouping = holdings_id,location
+
+; Text fields such as holdings_notes gathered from items to be displayed in each
+; holdings group in the display order.
+; The default list is 'holdings_notes', 'summary', 'supplements' and 'indexes'. The
+; deprecated field 'notes' is used as an alias for 'holdings_notes'.
+; Note that displayed information depends on what the ILS driver returns.
+;holdings_text_fields[] = 'holdings_notes'
+;holdings_text_fields[] = 'summary'
+
+; Whether support for multiple library cards is enabled. Default is false.
+;library_cards = true
+
+; The number of checked out items to display per page; 0 for no limit (may cause
+; memory problems for users with huge numbers of items). Default = 50.
+;checked_out_page_size = 50
+
+; The number of historic loans to display per page; 0 for no limit (may cause
+; memory problems for users with a large number of historic loans). Default = 50
+;historic_loan_page_size = 50
+
+; Whether to display the item barcode for each loan. Default is false.
+;display_checked_out_item_barcode = true
+
+; This section controls features related to user accounts
+[Account]
+; Allow the user to set a home library through the Profile screen, which will
+; override ILS-provided default pickup locations throughout the system.
+set_home_library = true
+
+; Allow the user to "subscribe" to search history entries in order to receive
+; email notifications of new search results.
+schedule_searches = false
+
+; Should we always send a scheduled search email the first time we run notices
+; after a user has subscribed (true), or should we only send an email when there
+; is actually something new (false, default)
+force_first_scheduled_email = false
+
+; When schedule_searches is set to true, you can customize the schedule frequencies
+; here -- just use the number of days between notifications in the brackets. Labels
+; will be run through the translator.
+;scheduled_search_frequencies[0] = schedule_none
+;scheduled_search_frequencies[1] = schedule_daily
+;scheduled_search_frequencies[7] = schedule_weekly
+
+; This section allows you to determine how the users will authenticate.
+; You can use an LDAP directory, the local ILS (or multiple ILSes through
+; the MultiILS option), the VuFind database (Database), a hard-coded list of
+; access passwords (PasswordAccess), AlmaDatabase (combination
+; of VuFind database and Alma account), Shibboleth, SIP2, CAS, Facebook, Email or
+; some combination of these (via the MultiAuth or ChoiceAuth options).
+;
+; The Email method is special; it is intended to be used through ChoiceAuth in
+; combination with Database authentication (or any other method that reliably stores
+; the user's email address) to make it possible to log in by receiving an
+; authentication link at the email address stored in VuFind's database. Email is
+; also supported as the primary authentication mechanism for some ILS drivers (e.g.
+; Alma). In these cases, ChoiceAuth is not needed, and ILS should be configured as
+; the Authentication method; see the ILS driver's configuration for possible options.
+;
+; Also note that the Email method stores hashes in your database's auth_hash table.
+; You should run the "php $VUFIND_HOME/public/index.php util expire_auth_hashes"
+; utility periodically to clean out old data in this table.
+[Authentication]
+;method          = LDAP
+;method         = ILS
+method         = Database
+;method         = AlmaDatabase
+;method         = Shibboleth
+;method         = SIP2
+;method         = CAS
+;method         = MultiAuth
+;method         = ChoiceAuth
+;method         = MultiILS
+;method         = Facebook
+;method         = PasswordAccess
+;method         = Email
+
+; This setting only applies when method is set to ILS.  It determines which
+; field of the ILS driver's patronLogin() return array is used as the username
+; in VuFind's user database.  If commented out, it defaults to cat_username
+; (the recommended setting in most situations).
+;ILS_username_field = cat_username
+
+; Whether or not to hide the Login Options; not that even when this is set to
+; false, ILS driver settings may be used to conditionally hide the login. See
+; hideLogin in the [Settings] section of NoILS.ini for an example.
+hideLogin = false
+
+; When set to true, uses AJAX calls to annotate the account menu with
+; notifications (overdue items, total fines, etc.)
+enableAjax = true
+
+; When set to true, replicates the account menu as a drop-down next to the
+; account link in the header.
+enableDropdown = false
+
+; Set this to false if you would like to store local passwords in plain text
+; (only applies when method = Database or AlmaDatabase above).
+hash_passwords = false
+
+; Allow users to recover passwords via email (if supported by Auth method)
+; You can set the subject of recovery emails in your
+; language files under the term "recovery_email_subject"
+recover_password = false
+; Time (seconds) before another recovery attempt can be made
+recover_interval      = 60
+; Length of time before a recovery hash can no longer be used (expires)
+; Default: Two weeks
+recover_hash_lifetime = 1209600
+
+; Allow users to set change their email address (if supported by Auth method).
+; When turning this on, it is also strongly recommended to turn on verify_email
+; below.
+change_email = false
+
+; Allow users to set change their passwords (if supported by Auth method)
+change_password = true
+
+; Force users to verify their email address before being able to log in
+; (only if method=Database) or make changes to it (if change_email=true).
+; If you wish to customize the email messages used by the system, see the
+; translation strings starting with verify and change_notification, as well as
+; the notify-email-change.phtml and verify-email.phtml Email templates.
+verify_email = false
+
+; Set this to false if you would like to store catalog passwords in plain text
+encrypt_ils_password = false
+
+; This is the key used to encrypt and decrypt catalog passwords.  This must be
+; filled in with a random string value when encrypt_ils_passwords is set to true.
+ils_encryption_key = false
+
+; This is the algorithm used to encrypt and decrypt catalog passwords.
+; A symmetrical encryption algorithm must be used.
+; You can use openssl_get_cipher_methods() to see available options on your system.
+; Common choices: blowfish (default), aes
+; If you want to convert from one algorithm to another, run this from $VUFIND_HOME:
+;   php public/index.php util switch_db_hash oldhash:oldkey (or none) newhash:newkey
+;ils_encryption_algo = "blowfish"
+
+; This setting may optionally be uncommented to restrict the email domain(s) from
+; which users are allowed to register when using the Database or AlmaDatabase method.
+;domain_whitelist[] = "myuniversity.edu"
+;domain_whitelist[] = "mail.myuniversity.edu"
+
+; Specify default minimum and maximum password length (Auth method may override
+; this).
+;minimum_password_length = 4
+;maximum_password_length = 32
+; Specify default limit of accepted characters in the password. Allowed values
+; are "numeric", "alphanumeric" or a regular expression
+;password_pattern = "(?=.*\d)(?=.*[a-z])(?=.*[A-Z])"
+; Specify default hint about what the password may contain when using a regexp
+; pattern. May be text or a translation key. The "numeric" and "alphanumeric"
+; patterns have translated default hints.
+;password_hint = "Include both upper and lowercase letters and at least one number."
+
+; Uncomment this line to switch on "privacy mode" in which no user information
+; will be stored in the database. Note that this is incompatible with social
+; features, password resets, and many other features. It is not recommended for
+; use with "Database" or "AlmaDatabase" authentication, since the user will be
+; forced to create a new account upon every login.
+;privacy = true
+
+; Allow a user to delete their account. Default is false.
+;account_deletion = true
+; Whether comments added by a user are deleted when they remove their account.
+; Default is true.
+;delete_comments_with_user = false
+
+; See the comments in library/VF/Auth/MultiAuth.php for full details
+; on using multiple authentication methods.  Note that MultiAuth assumes login
+; with username and password, so some methods (i.e. Shibboleth) may not be
+; compatible.
+;[MultiAuth]
+;method_order   = ILS,LDAP
+;filters = "username:trim,password:trim"
+
+; Present two auth options on the login screen. Each choice given must also be
+; configured in its relevant section. (The code should allow for more than 2
+; choices, but styling would need to be expanded / modified)
+;
+; WARNING! This module does not account for the possibility that the auth
+; choices you present may return different usernames. You would want a user to
+; be able to log in via any method and see the same account. To make sure that
+; is the case, you should ensure that the usernames given by the authentication
+; methods themselves are the same for any given user.
+;[ChoiceAuth]
+;choice_order = Shibboleth,Database
+
+; This section defines the location/behavior of the Solr index and requires no
+; changes for most installations
+[Index]
+; url can also be an array of servers. If so, VuFind will try the servers one by one
+; until one can be reached. This is only useful for advanced fault-tolerant Solr
+; installations.
+url             = http://localhost:8080/solr
+; Default bibliographic record core
+default_core    = biblio
+; Default authority record core
+default_authority_core = authority
+; This setting needs to match the <maxBooleanClauses> setting in your solrconfig.xml
+; file; when VuFind has to look up large numbers of records using ID values, it may
+; have to restrict the size of its result set based on this limitation.
+maxBooleanClauses = 1024
+; This is the timeout in seconds when communicating with the Solr server.
+timeout = 30
+; This is the Dismax handler to use if nothing is specified in searchspecs.yaml.
+; You can choose dismax for standard Dismax (the default) or edismax for Extended
+; Dismax, or you can configure your own custom handler in solrconfig.xml.
+default_dismax_handler = dismax
+; This is the number of records to retrieve in a batch e.g. when building a record
+; hierarchy. A higher number results in fewer round-trips but may increase Solr's
+; memory usage. Default is 1000.
+;cursor_batch_size = 1000
+
+
+; Enable/Disable searching reserves using the "reserves" Solr core.  When enabling
+; this feature, you need to run the util/index_reserves.php script to populate the
+; new index.
+[Reserves]
+search_enabled  = false
+
+; This section requires no changes for most installations; if your SMTP server
+; requires authentication, you can fill in a username and password below.
+[Mail]
+host            = localhost
+port            = 25
+;username       = user
+;password       = pass
+; The server name to report to the upstream mail server when sending mail.
+;name = vufind.myuniversity.edu
+; If a login is required you can define which protocol to use for securing the
+; connection. If no explicit protocol ('tls' or 'ssl') is configured, a protocol
+; based on the configured port is chosen (587 -> tls, 487 -> ssl).
+;secure         = tls
+; This setting enforces a limit (in seconds) on the lifetime of an SMTP
+; connection, which can be useful when sending batches of emails, since it can
+; help avoid errors caused by server timeouts. Comment out the setting to disable
+; the limit.
+connection_time_limit = 60
+; Uncomment this setting to disable outbound mail but simulate success; this
+; is useful for interface testing but should never be used in production!
+;testOnly = true
+; If set to false, users can send anonymous emails; otherwise, they must log in first
+require_login   = true
+; Should we put the logged-in user's address in the "from" field by default?
+user_email_in_from = false
+; Should we put the logged-in user's address in the "to" field by default?
+user_email_in_to = false
+; Should the user be allowed to edit email subject lines?
+user_editable_subjects = false
+; How many recipients is the user allowed to specify? (use 0 for no limit)
+maximum_recipients = 1
+; Populate the "from" field with this value if user_email_in_from is false and/or no
+; user is logged in:
+;default_from = "no-reply@myuniversity.edu"
+; Should we hide the "from" field in email forms? If no from field is visible, emails
+; will be sent based on user_email_in_from and default_from above, with the email
+; setting from the [Site] section used as a last resort.
+disable_from = false
+; From field override. Setting this allows keeping the "from" field in email forms
+; but will only use it as a reply-to address. The address defined here is used as the
+; actual "from" address.
+; Note: If a feature explicitly sets a different reply-to address (for example,
+; Feedback forms), the original from address will NOT override that reply-to value.
+;override_from = "no-reply@myuniversity.edu"
+
+; Being a special case of mail message, sending record results via SMS ("Text this")
+; may be "enabled" or "disabled" ("enabled" by default).
+; Should you choose to leave it enabled, see also sms.ini for further
+; configuration options.
+sms = enabled
+
+; Set this value to "database" to shorten links sent via email/SMS and
+; store its path in the database (default "none").
+url_shortener = none
+
+; This section needs to be changed to match your database connection information
+[Database]
+; Connection string format is [platform]://[username]:[password]@[host]:[port]/[db]
+; where:
+; [platform] = database platform (mysql, oci8 or pgsql)
+; [username] = username for connection
+; [password] = password for connection (optional)
+; [host] = host of database server
+; [port] = port of database server (optional)
+; [db] = database name
+database          = mysql://root@localhost/vufind
+
+; If your database (e.g. PostgreSQL) uses a schema, you can set it here:
+;schema = schema_name
+
+; The character set of the database -- may be latin1 or utf8; utf8 is STRONGLY
+; RECOMMENDED and is the default if no value is set here.  You may need latin1
+; for compatibility with existing VuFind 1.x installations.
+;charset = utf8
+
+; Reduce access to a set of single passwords
+; This is only used when Authentication method is PasswordAccess. See above.
+; Recommended to be used in conjunction with very restricted permissions.ini settings
+; and with most social settings disabled
+;[PasswordAccess]
+; access_user is a map of users to passwords
+; entering a correct password will login as that user
+;access_user[user] = password
+;access_user[admin] = superpassword
+
+; LDAP is optional.  This section only needs to exist if the
+; Authentication Method is set to LDAP.  When LDAP is active,
+; host, port, basedn and username are required.
+;[LDAP]
+; Prefix the host with ldaps:// to use LDAPS; omit the prefix for standard
+; LDAP with TLS.
+;host            = ldap.myuniversity.edu
+;port            = 389       ; LDAPS usually uses port 636 instead
+; By default, when you use regular LDAP (not LDAPS), VuFind uses TLS security.
+; You can set disable_tls to true to bypass TLS if your server does not support
+; it. Note that this setting is ignored if you use ldaps:// in the host setting.
+;disable_tls     = false
+;basedn          = "o=myuniversity.edu"
+;username        = uid
+; separator string for mapping multi-valued ldap-fields to a user attribute
+; if no separator is given, only the first value is mapped to the given attribute
+;separator = ';'
+; Optional settings to map fields in your LDAP schema to fields in the user table
+; in VuFind's database -- the more you fill in, the more data will be imported
+; from LDAP into VuFind:
+;firstname       = givenname
+;lastname        = sn
+;email           = mail
+;cat_username    =
+;cat_password    =
+;college         = studentcollege
+;major           = studentmajor
+; If you need to bind to LDAP with a particular account before
+; it can be searched, you can enter the necessary credentials
+; here.  If this extra security measure is not needed, leave
+; these settings commented out.
+;bind_username   = "uid=username o=myuniversity.edu"
+;bind_password   = password
+
+; SIP2 is optional.  This section only needs to exist if the
+; Authentication Method is set to SIP2.
+;[SIP2]
+;host            = ils.myuniversity.edu
+;port            = 6002
+
+; Shibboleth is optional.  This section only needs to exist if the
+; Authentication Method is set to Shibboleth. Be sure to set up authorization
+; logic in the permissions.ini file to filter users by Shibboleth attributes.
+;[Shibboleth]
+; Server param with the identity provider entityID if a Shibboleth session exists.
+; If omitted, Shib-Identity-Provider is used.
+;idpserverparam = Shib-Identity-Provider
+; Optional: Session ID parameter for SAML2 single logout support. If omitted, single
+; logout support is disabled. Note that if SLO support is enabled, Shibboleth session
+; ID's are tracked in external_session table which may need to be cleaned up with the
+; expire_session_mappings command line utility. See
+; https://vufind.org/wiki/configuration:shibboleth for more information on how
+; to configure the single logout support.
+;session_id = Shib-Session-ID
+; Optional: you may set attribute names and values to be used as a filter;
+; users will only be logged into VuFind if they match these filters.
+;userattribute_1       = entitlement
+;userattribute_value_1 = urn:mace:dir:entitlement:common-lib-terms
+;userattribute_2       = unscoped-affiliation
+;userattribute_value_2 = member
+; Required: the attribute Shibboleth uses to uniquely identify users.
+;username              = persistent-id
+; Required: Shibboleth login URL.
+;login                 = https://shib.myuniversity.edu/Shibboleth.sso/Login
+; Optional: Shibboleth logout URL.
+;logout                = https://shib.myuniversity.edu/Shibboleth.sso/Logout
+; Optional: URL to forward to after Shibboleth login (if omitted,
+; defaultLoggedInModule from [Site] section will be used).
+;target                = https://shib.myuniversity.edu/vufind/MyResearch/Home
+; Optional: provider_id (entityId) parameter to pass along to Shibboleth login.
+;provider_id           = https://idp.example.edu/shibboleth-idp
+; Some or all of the following entries may be uncommented to map Shibboleth
+; attributes to user database columns:
+;cat_username = HTTP_ALEPH_ID
+;cat_password = HTTP_CAT_PASSWORD
+;email = HTTP_MAIL
+;firstname = HTTP_FIRST_NAME
+;lastname = HTTP_LAST_NAME
+;college = HTTP_COLLEGE
+;major = HTTP_MAJOR
+;home_library = HTTP_HOME_LIBRARY
+
+; CAS is optional.  This section only needs to exist if the
+; Authentication Method is set to CAS.
+;[CAS]
+
+; Optional: the attribute CAS uses to uniquely identify users. (Omit to use
+; native CAS username instead of an attribute-based value).
+;username              = uid
+
+; Required: CAS Hostname.
+;server                = cas.myuniversity.edu
+
+; Required: CAS port.
+;port                 = 443
+
+; Required: CAS context.
+;context                 = /cas
+
+; Required: CAS Certificate Path. (Set to false to bypass authentication;
+; BYPASSING AUTHENTICATION IS *NOT* RECOMMENDED IN PRODUCTION).
+;CACert = /etc/pki/cert/cert.crt
+
+; Required: CAS login URL.
+;login                 = https://cas.myuniversity.edu/cas/login
+
+; Required: CAS logout URL.
+;logout                = https://cas.myuniversity.edu/cas/logout
+
+; Optional: CAS logging.
+;debug              = false
+;log                = /tmp/casdebug
+
+; Optional: URL to forward to after CAS login (if omitted,
+; defaultLoggedInModule from [Site] section will be used).
+;target                = http://lib.myuniversity.edu/vufind/MyResearch/Home
+
+; Optional: protocol to follow (legal values include CAS_VERSION_1_0,
+; CAS_VERSION_2_0, CAS_VERSION_3_0 and SAML_VERSION_1_1; default is
+; SAML_VERSION_1_1)
+;protocol = SAML_VERSION_1_1
+
+; Some or all of the following entries may be uncommented to map CAS
+; attributes to user database columns:
+;cat_username = acctSyncUserID
+;cat_password = catPassword
+;email = mail
+;firstname = givenName
+;lastname = sn
+;college = college
+;major = major1
+;home_library = library
+
+; Facebook may be used for authentication; fill in this section in addition to
+; turning it on in [Authentication] above to use it. You must register your
+; VuFind instance as an application at http://developers.facebook.com to obtain
+; credentials.
+;[Facebook]
+;appId = "your app ID"
+;secret = "your app secret"
+
+; This section controls the behavior of the cover generator when makeDynamicCovers
+; above is non-false.
+;
+; Note that any of these settings may be filtered to be size-specific by subscripting
+; the key with a size. You can use a key of * for a default to use when a specific
+; size is not matched. This allows adjustment of certain elements for different
+; thumbnail sizes. See the "size" setting below for an example.
+[DynamicCovers]
+; This controls the background layer of the generated image; options:
+; - solid: display a solid color
+; - grid: display a symmetrical random pattern seeded by title/callnumber
+;backgroundMode = grid
+
+; This controls the text layer of the generated image; options:
+; - default: display a title at the top and an author at the bottom
+; - initial: display only the first letter of the title as a stylized initial
+;textMode = default
+
+; Font files specified here should exist in the css/font subdirectory of a theme.
+; Some options are available by default inside the root theme.
+;authorFont = "Roboto-Light.ttf"
+;titleFont = "RobotoCondensed-Bold.ttf"
+
+; In 'default' textMode, covers are generated using title and author name; VuFind
+; will try to display everything by doing the following: break the title into
+; lines, and if the title is too long (more than maxTitleLines lines), it will
+; display ellipses at the last line.
+;
+; All text will be drawn using the specified textAlign alignment value using the
+; relevant titleFontSize or authorFontSize setting, except that author names will
+; be reduced to the minAuthorFontSize option if needed, and if that doesn't make
+; it fit, text will be aligned left and truncated.
+;
+; When using 'initial' textMode, maxTitleLines and author-related settings are
+; ignored as they do not apply.
+;textAlign = center
+;titleFontSize = 9
+;authorFontSize = 8
+;minAuthorFontSize = 7
+;maxTitleLines = 4
+
+; All color options support the same basic set of values:
+; - The 16 named colors from HTML4
+; - Arbitrary HTML hex colors in the form #RRGGBB (e.g. #FFFF00 for yellow)
+; Some color options also support additional options.
+; - authorFillColor,titleFillColor: the main color used
+; - authorBorderColor,titleBorderColor: the color used to make a border; "none" is
+;   a legal option in addition to colors.
+; - baseColor: When using grid backgrounds, you may also choose a base color drawn
+;   beneath the grid. Default is white.
+; - accentColor: When using solid backgrounds, this is the background color; when
+;   using grid backgrounds, this is the color of the grid pattern beneath the text.
+;   You may set this to "random" to select a random color seeded with text from
+;   the cover and adjusted with the "lightness" and "saturation" settings below.
+;titleFillColor = black
+;titleBorderColor = none
+;authorFillColor = white
+;authorBorderColor = black
+;baseColor = white
+;accentColor = random
+; Note: lightness and saturation are only used when accentColor = random. Legal
+; ranges are 0-255 for each value.
+;lightness = 220
+;saturation = 80
+
+; These settings control the size of the image -- if size is a single number, a
+; square will be created; if it is a string containing an "x" (i.e. 160x190) it
+; defines a WxH rectangle. wrapWidth constrains the text size (and must be no
+; larger than the width of the canvas). topPadding and bottomPadding push the
+; text away from the edges of the canvas.
+;size[*] = 128
+;size[medium] = 200
+;size[large] = 500
+;topPadding = 19
+;bottomPadding = 3
+;wrapWidth = 110
+
+; This section is needed for Buchhandel.de cover loading. You need an authentication
+; token. It may also be necessary to customize your templates in order to comply with
+; terms of service; please look at http://info.buchhandel.de/handbuch_links for
+; details before turning this on.
+[Buchhandel]
+url = "https://api.vlb.de/api/v1/cover/"
+; token = "XXXXXX-XXXX-XXXXX-XXXXXXXXXXXX"
+
+[QRCode]
+; This setting controls the image to display when no qrcode is available.
+; The path is relative to the base of your theme directory.
+;noQRCodeAvailableImage = images/noQRCode.gif
+
+; Should we show QR codes in search results?
+;showInResults = true
+
+; Should we show QR codes on record pages?
+;showInCore = true
+
+; If you are using Syndetics Plus for *any* content, set plus = true
+; and set plus_id to your syndetics ID.  This loads the javascript file.
+; Syndetics vs. SyndeticsPlus: SyndeticsPlus has nice formatting, but loads slower
+; and requires javascript to be enabled in users' browsers.
+; set use_ssl to true if you serve your site over ssl and you
+; use SyndeticsPlus to avoid insecure content browser warnings
+; (or if you just prefer ssl)
+; NOTE: SyndeticsPlus is incompatible with the tabs/accordion [List] views in
+;       searches.ini. Do not turn it on if you are using these optional features.
+[Syndetics]
+use_ssl = false
+plus = false
+;plus_id = "MySyndeticsId"
+; timeout value (in seconds) for API calls:
+timeout = 10
+
+; Booksite CATS Enhanced Content - cover images, reviews, description, etc.
+[Booksite]
+url = "https://api.booksite.com"
+;key = "XXXXXXXXXXXXXXXXX"
+
+; Content Cafe is a subscription service from Baker & Taylor. If you are using this
+; service (see the [Content] section above for details), you MUST uncomment and set
+; the password (pw) setting. You may also change the API base URL (url) if needed.
+[Contentcafe]
+;url              = "http://contentcafe2.btol.com"
+;pw               = "xxxxxx"
+
+; Summon is optional; this section is used for your API credentials. apiId is the
+; short, human-readable identifier for your Summon account; apiKey is the longer,
+; non-human-readable secret key. See also the separate Summon.ini file.
+;[Summon]
+;apiId        = myAccessId
+;apiKey       = mySecretKey
+
+; This section must be filled in if you plan to use the optional WorldCat
+; search module. Otherwise, it may be ignored.
+;[WorldCat]
+;Your WorldCat search API key
+;apiKey          = "long-search-api-key-goes-here"
+;Your holdings symbol (usually a three-letter code) - used for excluding your
+; institution's holdings from the search results.
+;OCLCCode        = MYCODE
+
+; This section must be filled in to use Relais (E-ZBorrow) functionality. When
+; activated, this function will allow users to place ILL requests on unavailable
+; items through the record holdings tab.
+;
+; If you set apikey below, requests may be made from within VuFind through a
+; pop-up; if you omit apikey but set loginUrl and symbol, links will be provided
+; to Relais. Setting loginUrl and symbol is strongly recommended in all cases,
+; since links will be used as a fallback if the API fails.
+;[Relais]
+; Your library's holdings symbol (e.g. PVU for Villanova)
+;symbol="XYZ"
+; The pickup location to use for your institution (currently multiple pickup
+; locations are not supported here).
+;pickupLocation = "DEFAULT"
+; Barcode number (or other user ID) to use for lookups when none is provided
+;patronForLookup="99999999"
+; API key (may vary for testing vs. production)
+;apikey="your-relais-api-key-goes-here"
+; Timeout for HTTP requests (in seconds; set high, as Relais can be slow)
+;timeout = 500
+; Your institution's login URL for the remote Relais system (used to provide
+; a link when the API fails)
+;loginUrl = https://e-zborrow.relais-host.com/user/login.html
+
+; TEST VALUES (uncomment for testing)
+;group="DEMO"
+;authenticateurl="https://demo.relais-host.com/portal-service/user/authentication"
+;availableurl="https://demo.relais-host.com/dws/item/available"
+;addurl="https://demo.relais-host.com/dws/item/add"
+
+; PRODUCTION VALUES (uncomment for live use)
+;group="EZB"
+;authenticateurl="https://e-zborrow.relais-host.com/portal-service/user/authentication"
+;availableurl="https://e-zborrow.relais-host.com/dws/item/available"
+;addurl="https://e-zborrow.relais-host.com/dws/item/add"
+
+; DPLA key -- uncomment and fill in to use DPLATerms recommendations (see also
+; searches.ini).
+;[DPLA]
+;apiKey = http://dp.la/info/developers/codex/policies/#get-a-key
+
+; These settings affect dynamic DOI-based link inclusion; this can provide links
+; to full text or contextual information.
+[DOI]
+; This setting controls whether or not DOI-based links are enabled, and which
+; API is used to fetch the data. Currently supported options: BrowZine (requires
+; credentials to be configured in BrowZine.ini), Unpaywall or false (to disable). Disabled
+; by default.
+;resolver = BrowZine
+
+;unpaywall_api_url = "https://api.unpaywall.org/v2"
+; Unpaywall needs an email adress, see https://unpaywall.org/products/api
+;unpaywall_email = "your@email.org"
+
+; The following settings control where DOI-based links are displayed:
+show_in_results = true      ; include in search results
+show_in_record = false      ; include in core record metadata
+show_in_holdings = false    ; include in holdings tab of record view
+
+; These settings affect OpenURL generation and presentation; OpenURLs are used to
+; help users find resources through your link resolver and to manage citations in
+; Zotero.
+[OpenURL]
+; If a resolver base URL is enabled, it will be used to link from records to your
+; OpenURL resolver. An OpenURL resolver is typically used to e.g. link to full text
+; from article metadata, but it may provide other services too. Extra parameters may
+; be added if necessary.
+;url             = "http://openurl.myuniversity.edu/sfx_local"
+
+; This string will be included as part of your OpenURL referer ID (the full string
+; will be "info:sid/[your rfr_id setting]:generator").  You may be able to configure
+; special behavior in your link resolver based on this ID -- for example, you may
+; wish to prevent the resolver from linking to VuFind when links came from VuFind
+; (to avoid putting a user in an infinite loop).
+rfr_id          = vufind.svn.sourceforge.net
+
+; By specifying your link resolver type, you can allow VuFind to optimize its
+; OpenURLs for a particular platform.  Current legal values: "sfx", "360link",
+; "EZB", "Redi", "Alma", "demo" or "generic" (default is "generic" if commented out;
+; "demo" generates fake values for use in testing the embed setting below).
+;resolver        = sfx
+
+; If you want OpenURL links to open in a new window, set this setting to the
+; desired Javascript window.open parameters.  If you do not want a new window
+; to open, set this to false or comment it out.
+window_settings = "toolbar=no,location=no,directories=no,buttons=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=550,height=600"
+
+; If you want to display a graphical link to your link resolver, uncomment the
+; settings below.  graphic should be a URL; graphic_width and graphic_height
+; should be sizes in pixels.
+;graphic = "http://myuniversity.edu/images/findIt.gif"
+;graphic_width = 50
+;graphic_height = 20
+
+; If your link resolver can render an image in response to an OpenURL, you can
+; specify the base URL for image generation here:
+;dynamic_graphic = "http://my-link-resolver/image"
+
+; If dynamic_graphic is set above, the dynamic image can be used instead of the
+; standard text or static-image-based OpenURL link (true), it can be disabled
+; (false), or it can be displayed in addition to the regular link ("both").
+;image_based_linking_mode = both
+
+; The following settings control where OpenURL links are displayed:
+show_in_results = true      ; include in search results
+show_in_record = false      ; include in core record metadata
+show_in_holdings = false    ; include in holdings tab of record view
+
+; If set to true, this setting will attempt to embed results from the link
+; resolver directly in search results instead of opening a new window or page.
+; This will override the window_settings option if set! Embedding is currently
+; unsupported when the resolver setting above is set to "other".
+embed = false
+
+; When embed is true and this is set to true results from the link resolver will
+; be loaded automatically (default is false, which requires a user click to trigger
+; the loading). Alternatively you can provide a comma-separated list of view areas
+; (cf. show_in_* settings) to autoload embedded OpenURLs only in certain views.
+; Notice: autoloading in results view might put some load on your linkresolver (each
+; results view could perform searches.ini->[General]->default_limit requests). You
+; might reduce load on the linkresolver by using the resolver_cache setting (see
+; below).
+embed_auto_load = false
+
+; When embed is true, you can set this to an absolute path on your system in order
+; to cache link resolver results to disk.  Be sure that the chosen directory has
+; appropriate permissions set!  Leave the setting commented out to skip caching.
+; Note that the contents of this cache will not be expired by VuFind; you should
+; set up an external process like a cron job to clear out the directory from time
+; to time.
+;resolver_cache = /usr/local/vufind/resolver_cache
+
+; This setting controls whether we should display an OpenURL link INSTEAD OF other
+; URLs associated with a record (true) or IN ADDITION TO other URLs (false).
+replace_other_urls = true
+
+; EZproxy is optional.  This section only needs to exist if you
+; are using EZProxy to provide off-site access to online materials.
+;[EZproxy]
+;host            = http://proxy.myuniversity.edu
+
+; By default, when the 'host' setting above is active, VuFind will prefix links in
+; records using EZproxy's "?qurl=" mechanism. If you need to set a host for ticket
+; authentication (below) but you want to disable the prefixing behavior, set this
+; to false.
+;prefixLinks = true
+
+; Uncomment the following line and change the password to something secret to enable
+; EZproxy ticket authentication.
+;secret = "verysecretpassword"
+;
+; To enable ticket authentication in EZproxy, you will also need the following in
+; EZproxy's user.txt or ezproxy.usr for older versions (without the leading
+; semicolons and spaces):
+;
+; ::CGI=https://vufind-server/ExternalAuth/EzproxyLogin?url=^R
+; ::Ticket
+; TimeValid 10
+; SHA512 verysecretpassword
+;
+; Uncomment and modify the following line to use another hashing algorithm with the
+; EZproxy authentication if necessary. SHA512 is the default, but it requires at
+; least EZproxy version 6.1. Use "SHA1" for older EZproxy versions, and remember to
+; replace SHA512 with SHA1 also in EZproxy's configuration file.
+;secret_hash_method = "SHA512"
+
+; Uncomment the following line to disable relaying of user name to EZproxy on ticket
+; authentication:
+;anonymous_ticket = true
+; Uncomment the following line to disable logging of successful ticket
+; authentication requests in VuFind:
+;disable_ticket_auth_logging = true
+
+; These settings affect RefWorks record exports.  They rarely need to be changed.
+[RefWorks]
+vendor          = VuFind
+url             = https://www.refworks.com
+
+; These settings affect EndNote Web record exports.  They rarely need to be changed.
+[EndNoteWeb]
+vendor          = VuFind
+url             = https://www.myendnoteweb.com/EndNoteWeb.html
+
+; These settings affect your OAI server if you choose to use it.
+;
+; If identifier is set, its value will be used as part of the standard OAI
+; identifier prefix.  It should only ever be set to a domain name that you
+; control!  If it is not set, your ID values will not be prefixed.
+;
+; If admin_email is not set, the main email under [Site] will be used instead.
+;
+; page_size may be used to specify the number of records returned per request.
+; Default is 100. A higher number may improve overall harvesting performance, but
+; will also make a single response page larger and slower to produce.
+;
+; If set_field is set, the named Solr field will be used to generate sets on
+; your OAI-PMH server.  If it is not set, sets will not be supported.
+;
+; If set_query is set (as an array mapping set names to Solr queries -- see
+; examples below), the specified queries will be exposed as OAI sets.  If
+; you use both set_field and set_query, be careful about the names you choose
+; for your set queries. set_query names will trump set_field values when
+; there are collisions.
+;
+; default_query may be used to specify a filter for the default set, i.e. records
+; returned when a set is not specified.
+;
+; If vufind_api_format_fields is set, the listed fields (as defined in
+; SearchApiRecordFields.yaml) are returned when metadata prefix
+; "oai_vufind_json" is used.
+;
+; record_format_filters allows mapping from requested OAI metadataPrefix to query
+; filters. They can be used e.g. to limit results to records that can be returned in
+; the requested format.
+;
+; delete_lifetime controls how many days' worth of deleted records to include in
+; responses. Records deleted before the cut-off will not be included in responses.
+; Omit this setting to return all deleted records. This can be useful for long-lived
+; systems with many deleted records, to prevent full harvests from becoming unwieldy.
+;
+;[OAI]
+;identifier       = myuniversity.edu
+;repository_name  = "MyUniversity Catalog"
+;admin_email      = oai@myuniversity.edu
+;page_size        = 1000
+;set_field        = "format"
+;set_query['eod_books'] = "institution:kfu AND publishDate:[1911 TO 1911]"
+;set_query['eod_ebooks'] = "format:eBook"
+;default_query = "institution:kfu"
+;vufind_api_format_fields = "id,authors,cleanIsbn,cleanIssn,formats,title"
+;record_format_filters[marc21] = "record_format:marc"
+;delete_lifetime = 365
+
+; Proxy Server is Optional.
+[Proxy]
+;host = your.proxy.server
+;port = 8000
+
+; Uncomment following line to set proxy type to SOCKS 5
+;type = socks5
+
+; Default HTTP settings can be loaded here. These values will be passed to
+; the \Zend\Http\Client's setOptions method.
+[Http]
+;sslcapath = "/etc/ssl/certs" ; e.g. for Debian systems
+;sslcafile = "/etc/pki/tls/cert.pem" ; e.g. for CentOS systems
+
+;timeout = 30 ; default timeout if not overridden by more specific code/settings
+
+; Example: Using a CURL Adapter instead of the the defaultAdapter (Socket); note
+; that you may also need to install CURL and PHP/CURL packages on your server.
+;adapter = 'Zend\Http\Client\Adapter\Curl'
+
+; Spelling Suggestions
+;
+; Note: These settings affect the VuFind side of spelling suggestions; you
+; may also wish to adjust some Solr settings in solr/biblio/conf/schema.xml
+; and solr/biblio/conf/solrconfig.xml.
+[Spelling]
+enabled = true
+; Number of suggestions to display on screen. This list is filtered from
+;   the number set in solr/biblio/conf/solrconfig.xml so they can differ.
+limit   = 3
+; Show the full modified search phrase on screen
+;   rather then just the suggested word
+phrase = false
+; Offer expansions on terms as well as basic replacements
+expand  = true
+; Turning on 'simple' spell checking will improve performance,
+;  by ignoring the more complicated 'shingle' (mini phrases)
+;  based dictionary.
+simple = false
+; This setting skips spell checking for purely numeric searches; spelling
+; suggestions on searches for ISBNs and OCLC numbers are not generally very
+; useful.
+skip_numeric = true
+
+; These settings control what events are logged and where the information is
+; stored.
+;
+; VuFind currently supports four logging levels: alert (severe fatal error),
+; error (fatal error), notice (non-fatal warning) and debug (informational).
+;
+; Each logging level can be further broken down into five levels of verbosity.
+; You can specify the desired level by adding a dash and a number after the
+; level in the configuration string -- for example, alert-2 or error-5.
+; The higher the number, the more detailed the logging messages.  If verbosity
+; is not specified, it defaults to 1 (least detailed).
+;
+; Several logging methods are available, and each may be configured to log any
+; combination of levels.
+;
+; You may enable multiple logging mechanisms if you want -- in fact, it is
+; recommended, since the failure of one logging mechanism (i.e. database down,
+; file system full) may then be reported to another.
+;
+; If database is uncommented, messages will be logged to the named MySQL table.
+; The table can be created with this SQL statement:
+; CREATE TABLE log_table ( id INT NOT NULL AUTO_INCREMENT,
+;     logtime TIMESTAMP NOT NULL, ident CHAR(16) NOT NULL,
+;     priority INT NOT NULL, message TEXT, PRIMARY KEY (id) );
+;
+; If file is uncommented, messages will be logged to the named file.  Be sure
+; that Apache has permission to write to the specified file!
+;
+; If email is uncommented, messages will be sent to the provided email address.
+; Be careful with this setting: a flood of errors can easily bog down your mail
+; server!
+[Logging]
+;database       = log_table:alert,error,notice,debug
+; NOTE : Make sure the log file exists and that Apache has write permission.
+; NOTE : Windows users should avoid drive letters (eg. c:\vufind) because
+;        the colon will be used in the string parsing. "/vufind" will work
+;file           = /var/log/vufind.log:alert,error,notice,debug
+;email          = alerts@myuniversity.edu:alert-5,error-5
+
+; Get URL from https://YOURSLACK.slack.com/apps/manage/custom-integrations
+;slack = #channel_name:alert,error
+;slackurl = https://hooks.slack.com/services/your-private-details
+;slackname = "VuFind Log" ; username messages are posted under
+; You can also use the Slack settings to hook into Discord:
+; - Get your url from Server Settings > Webhooks
+; - Add /slack to the end of your url for Slack-compatible messages
+; https://discordapp.com/developers/docs/resources/webhook#execute-slackcompatible-webhook
+
+; This section can be used to specify a "parent configuration" from which
+; the current configuration file will inherit.  You can chain multiple
+; configurations together if you wish.
+[Parent_Config]
+; Full path to parent configuration file:
+;path = /usr/local/vufind/application/config/config.ini
+; Path to parent configuration file (relative to the location of this file):
+;relative_path = ../masterconfig/config.ini
+
+; A comma-separated list of config sections from the parent which should be
+; completely overwritten by the equivalent sections in this configuration;
+; any sections not listed here will be merged on a section-by-section basis.
+;override_full_sections = "Languages,AlphaBrowse_Types"
+
+; This setting is for allowing arrays to be merged with the values of their parents
+; arrays. If override_full_sections is set for a section the arrays will always be
+; overridden.
+; For legacy reasons merging of arrays is disabled by default.
+;merge_array_settings = false
+
+; This section controls which language options are available to your users.
+; If you offer more than one option, a control will appear in the user
+; interface to allow user selection.  If you only activate one language,
+; the control will be hidden.
+;
+; The name of each setting below (i.e. en, de, fr) is a language code and
+; corresponds with one of the translation files found in the web/lang
+; directory.  The value of each setting is the on-screen name of the language,
+; and will itself be subject to translation through the language files!
+;
+; The order of the settings is significant -- they will be displayed on screen
+; in the same order they are defined here.
+;
+; Be sure that this section includes the default language set in the [Site]
+; section above.
+[Languages]
+en          = "English"              ; American spellings
+;en-gb       = "English"              ; British spellings
+de          = "German"
+es          = "Spanish"
+fr          = "French"
+it          = "Italian"
+ja          = "Japanese"
+nl          = "Dutch"
+;nl-be       = "Flemish Dutch"
+pt          = "Portuguese"
+pt-br       = "Brazilian Portugese"
+zh-cn       = "Simplified Chinese"
+zh          = "Chinese"
+tr          = "Turkish"
+he          = "Hebrew"
+ga          = "Irish"
+cy          = "Welsh"
+el          = "Greek"
+ca          = "Catalan"
+eu          = "Basque"
+ru          = "Russian"
+cs          = "Czech"
+fi          = "Finnish"
+sv          = "Swedish"
+pl          = "Polish"
+da          = "Danish"
+sl          = "Slovene"
+ar          = "Arabic"
+bn          = "Bengali"
+gl          = "Galician"
+vi          = "Vietnamese"
+hr          = "Croatian"
+hi          = "Hindi"
+
+; This section contains special cases for languages such as right-to-left support
+[LanguageSettings]
+; Comma-separated list of languages to display in right-to-left mode
+rtl_langs = "ar,he"
+
+; This section controls the behavior of the Browse module.  The result_limit
+; setting controls the maximum number of results that may display in any given
+; result box on the Browse screen.  You can set to -1 for no limit; however,
+; setting a very high (or no) limit may result in "out of memory" errors if you
+; have a large index!
+[Browse]
+result_limit    = 100
+tag             = true      ; allow browsing of Tags
+dewey           = false     ; allow browsing of Dewey Decimal call numbers
+lcc             = true      ; allow browsing of LC call numbers
+author          = true      ; allow browsing of authors
+topic           = true      ; allow browsing of subject headings
+genre           = true      ; allow browsing of genre subdivisions
+region          = true      ; allow browsing of region subdivisions
+era             = true      ; allow browsing of era subdivisions
+; You can use this setting to change the default alphabet provided for browsing:
+;alphabet_letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+; Uncomment to sort lists alphabetically (instead of by popularity); note that
+; this will not changed the values returned -- you will still get only the
+; <result_limit> most popular entries -- it only affects display order.
+;alphabetical_order = true
+
+; This section controls the availability of export methods.
+;
+; Each entry may be a comma-separated list of contexts in which the export
+; option will be presented. Valid options:
+;
+; bulk - Included in batch export contexts
+; record - Included in single-record export contexts
+;
+; If you simply set a field to true, only "record" mode will be enabled.
+; If you set a field to false, all export contexts will be disabled.
+;
+; Note that some options may be disabled for records that do not support them,
+; regardless of the setting chosen here.  You can edit the separate export.ini
+; file to add new export formats and change the behavior of existing ones.
+[Export]
+RefWorks = "record,bulk"
+EndNote = "record,bulk"
+EndNoteWeb = "record,bulk"
+MARC = false
+MARCXML = false
+RDF = false
+BibTeX = false
+RIS = false
+
+[BulkExport]
+; Export behavior to use when no bulkExportType setting is found in the matching
+; format section of export.ini; default is 'link' if not overridden below. See
+; export.ini for more details on available options.
+;defaultType = download
+
+;AddThis is optional. It uses the Add This tool available from www.addthis.com
+; and requires the username generated when an analytics account is registered.
+;[AddThis]
+;key = yourUsername
+
+; This section controls how item status information is presented in search results.
+[Item_Status]
+; Usually, there is only one location or call number for each item; however, when
+; multiple values are found, there are several possible behaviors:
+;     first = display the first value found, ignore the rest
+;     all   = show all of the values found, separated by commas
+;     msg   = show a message like "Multiple Call Numbers" or "Multiple Locations"
+;     group = show availability statuses for each location on a separate line,
+;             followed by callnumber information (valid for multiple_locations only)
+multiple_call_nos = first
+multiple_locations = msg
+
+; If your ILS driver supports services, VuFind will display a more detailed
+; availability message. This setting may be used to indicate that one particular
+; status is preferred over all others and should be displayed by itself when
+; found. This is useful because some drivers will always provide both "loan" and
+; "presentation" services, but most users will only care about "loan" (since in-
+; library use is implied by the ability to borrow an item). Set this to false to
+; always display all services.
+preferred_service = "loan"
+
+; Show the full location, call number, availability for each item.
+; You can customize the way each item's status is displayed by overriding the
+; ajax/status-full.phtml template.
+; When enabled, this causes the multiple_call_nos, multiple_locations and
+; preferred_service settings to be ignored.
+show_full_status = false
+
+; You can set this to the name of an alphabetic browse handler (see the
+; [AlphaBrowse_Types] section) in order to link call numbers displayed on the
+; holdings tab and in status messages to a specific browse list. Set to false
+; to disable call number linking.
+callnumber_handler = false
+
+; This section controls the behavior of the Record module.
+[Record]
+; Set this to true in order to enable "next" and "previous" links to navigate
+; through the current result set from within the record view.
+next_prev_navigation = false
+
+; Set this to true in order to enable "first" and "last" links to navigate
+; through the content result set from within the record view. Note, this
+; may cause slow behavior with some installations. The option will only work
+; when next_prev_navigation is also set to true.
+first_last_navigation = false
+
+; Setting this to true will cause VuFind to skip the results page and
+; proceed directly to the record page when a search has only one hit.
+jump_to_single_search_result = false
+
+; You can enable this setting to show links to related MARC records using certain
+; 7XX fields. Just enter a comma-separated list of the MARC fields that you wish
+; to make use of.
+;marc_links = "760,762,765,767,770,772,773,774,775,776,777,780,785,787"
+; In the marc_links_link_types enter the fields you want the module to use to
+; construct the links. The module will run through the link types in order
+; until it finds one that matches. If you don't have id numbers in the fields,
+; you can also use title to construct a title based search. id represents a raw
+; bib id, dlc represents an LCCN.  Default setting:
+;marc_links_link_types = id,oclc,dlc,isbn,issn,title
+; Set use_visibility_indicator to false if you want to show links that are marked as
+; "Do not show" in the MARC record (indicator 1). Otherwise, these links will be
+; suppressed. (Default = true)
+;marc_links_use_visibility_indicator = false
+
+; When displaying publication information from 260/264, this separator will be
+; placed between repeating subfield values (default is to rely on existing ISBD
+; punctuation, but this can be used when ISBD punctuation is absent (e.g. ", ").
+;marcPublicationInfoSeparator = " "
+
+; When displaying publication information from 260/264, this can be set to true
+; to make 264 information completely replace 260 information. Default is false,
+; which will display information from 260 AND 264 when both fields are populated.
+; Note that this only affects display, not indexing; both fields will always be
+; made searchable.
+;replaceMarc260 = false
+
+; Set the URI-pattern of the server which serves the raw Marc-data. (see
+; https://vufind.org/wiki/configuration:remote_marc_records for more information
+; on how to set up a remote service for raw Marc-data)
+;remote_marc_url = http://127.0.0.1/%s
+
+; You can use this setting to hide holdings information for particular named locations
+; as returned by the catalog.
+hide_holdings[] = "World Wide Web"
+
+; This array controls which Related modules are used to display sidebars on the
+; record view page.
+;
+; Available options:
+;    Channels - Display links to channels of content related to record
+;    Similar - Similarity based on Solr lookup
+;    WorldCatSimilar - Similarity based on WorldCat lookup
+related[] = "Similar"
+
+; This setting controls which citations are available; set to true for all supported
+; options (default); set to false to disable citations; set to a comma-separated list
+; to activate only selected formats (available options: APA, Chicago, MLA). The
+; comma-separated list option may also be used to customize citation display order.
+;citation_formats = APA,Chicago,MLA
+
+; The following two sections control the Alphabetic Browse module.
+[AlphaBrowse]
+; This setting controls how many headings are displayed on each page of results:
+page_size = 20
+; How many headings to show before the match (or the spot where the match
+; would have been found). Default is 0 for backwards compatibility.
+rows_before = 0
+; highlight the match row (or spot where match would have been)? default false
+highlighting = false
+; SEE ALSO: the General/includeAlphaBrowse setting in searchbox.ini, for including
+; alphabrowse options in the main search drop-down options.
+
+; This section controls the order and content of the browse type menu in the
+; Alphabetic Browse module.  The key is the browse index to use, the value is the
+; string to display to the user (subject to translation).
+[AlphaBrowse_Types]
+topic = "By Topic"
+author = "By Author"
+title = "By Title"
+lcc = "By Call Number"
+;dewey = "By Call Number"
+
+; This section controls the return of extra columns for the different browses.
+; The key is the browse index, the value is a colon-separated string of extra
+; Solr fields to return for display to the user.
+; Values should be in translation file as browse_value.
+[AlphaBrowse_Extras]
+title = "author:format:publishDate"
+lcc = title
+dewey = title
+
+; This section allows you to configure the values used for Cryptography; the
+; HMACkey can be set to any value you like and should never be shared.  It is used
+; to prevent users from tampering with certain URLs (for example, "place hold" form
+; submissions)
+[Security]
+HMACkey = mySuperSecretValue
+
+; This section sets global defaults for caches; file caching is used by default.
+; A custom directory for caching can be defined by the environment variable
+; VUFIND_CACHE_DIR (see httpd-vufind.conf). The default location is inside the
+; local settings directory.
+[Cache]
+; Set time to live value for Zend caches (in seconds), 0 means maximum possible.
+;ttl = 0
+; Override umask for cache directories and files.
+;umask = 022
+; Permissions for Zend-created cache directories and files, subject to umask
+; Default dir_permission seems to be 0700.
+;dir_permission = 0700
+; Default file_permission seems to be 0600.
+;file_permission = 0600
+
+; This section controls the "Collections" module -- the special view for records
+; that represent collections, and the mechanism for browsing these records.
+[Collections]
+; Control whether or not the collections module is enabled in search results.
+; If set to true any search results which are collection level items will
+; link to the respective collections page rather than the record page
+; (default = false).
+;collections = true
+; Control default tab of Collection view (default = CollectionList); see also
+; CollectionTabs.ini.
+;defaultTab = CollectionList
+; This controls where data is retrieved from to build the Collections/Home page.
+; It can be set to Index (use the Solr index) or Alphabetic (use the AlphaBrowse
+; index). Index is subject to "out of memory" errors if you have many (150000+)
+; collections; Alphabetic has no memory restrictions but requires generation of
+; a browse index using the index-alphabetic-browse tool.  (default = Index)
+;browseType = Index
+; This string is the delimiter used between title and ID in the hierarchy_browse
+; field of the Solr index.  Default is "{{{_ID_}}}" but any string may be used;
+; be sure the value is consistent between this configuration and your indexing
+; routines.
+;browseDelimiter = "{{{_ID_}}}"
+; This controls the page size within the Collections/Home page (default = 20).
+;browseLimit = 20
+; List of record routes that are converted to collection routes (used to map
+; route names when a record identifies itself as a collection and the collections
+; setting above is true).
+route[record] = collection
+route[search2record] = search2collection
+
+; This section addresses hierarchical records in the Solr index
+[Hierarchy]
+; Name of hierarchy driver to use if no value is specified in the hierarchytype
+; field of the Solr index.
+driver = Default
+; Should we display hierarchy trees? (default = false)
+;showTree = true
+; "Search within trees" can be disabled here if set to "false" (default = true)
+search = true
+; You can limit the number of search results highlighted when searching the tree;
+; a limit is recommended if you have large trees, as otherwise large numbers of
+; results can cause performance problems.  If treeSearchLimit is -1 or not set,
+; results will be unlimited.
+treeSearchLimit = 100
+; Whether hierarchy fields are used for linking between container records and their
+; children (default = false). This is an alternative to the full collections support
+; (see the [Collections] section), so only one of them should be enabled
+; at a time e.g. unless custom record drivers are used. When using this setting,
+; you may also wish to enable the ComponentParts tab in RecordTabs.ini.
+;simpleContainerLinks = true
+
+; This section will be used to configure the feedback module.
+; Set "tab_enabled" to true in order to enable the feedback module.
+; Forms are configured in FeedbackForms.yaml
+[Feedback]
+;tab_enabled       = true
+
+; Default values for form recipient and email subject, if not overridden for a
+; specific form in FeedbackForms.yaml
+;recipient_email   = "feedback@myuniversity.edu"
+;recipient_name    = "Your Library"
+;email_subject     = "VuFind Feedback"
+
+; This is the information for where feedback emails are sent from.
+;sender_email      = "noreply@vufind.org"
+;sender_name       = "VuFind Feedback"
+
+; Note: for additional details about stats (including additional notes on Google
+; Analytics and Piwik), look at the wiki page:
+;     https://vufind.org/wiki/configuration:usage_stats
+
+; Uncomment this section and provide your API key to enable Google Analytics. Be
+; sure to set the "universal" setting to true once your account is upgraded to
+; Universal Analytics; see:
+; https://developers.google.com/analytics/devguides/collection/upgrade/guide
+;[GoogleAnalytics]
+;apiKey = "mykey"
+;universal = false
+
+; Uncomment this section and provide your Piwik server address and site id to
+; enable Piwik analytics. Note: VuFind's Piwik integration uses several custom
+; variables; to take advantage of them, you must reconfigure Piwik by switching
+; to its root directory and running this command to raise a default limit:
+; ./console customvariables:set-max-custom-variables 10
+[Piwik]
+;url = "http://server.address/piwik/"
+;site_id = 1
+; Uncomment the following setting to track additional information about searches
+; and displayed records with Piwik's custom variables
+;custom_variables = true
+; By default, Piwik searches are tracked using the format "Backend|Search Terms."
+; If you need to differentiate searches coming from multiple VuFind instances using
+; a shared site_id, you can set the searchPrefix to add an additional prefix to
+; the string, for example "SiteA|Backend|Search Terms." Most users will want to
+; leave this disabled.
+;searchPrefix = "SiteA|"
+; Uncomment the following setting to disable cookies for privacy reasons.
+; see https://matomo.org/faq/general/faq_157/ for more information.
+;disableCookies = true
+
+; Uncomment portions of this section to activate tabs in the search box for switching
+; between search modules. Keys are search backend names, values are labels for use in
+; the user interface (subject to translation). If you need multiple tabs for a single
+; backend, append a colon and a suffix to each backend name (e.g. Solr:main) and add
+; the filters in the [SearchTabsFilters] section.
+[SearchTabs]
+;Solr = Catalog
+;Summon = Summon
+;WorldCat = WorldCat
+;Solr:filtered = "Catalog (Main Building Books)"
+;EDS = "EBSCO Discovery Service"
+;EIT = "EBSCO Integration Toolkit"
+;Primo = "Primo Central"
+
+; Add any hidden filters in this section for search tab specific filtering
+[SearchTabsFilters]
+;Solr:filtered[] = 'building:"main library"'
+;Solr:filtered[] = "format:book"
+
+; You can bind a permission to a search tab in this section.
+; This controls to whom the tab should be displayed.
+; Use the format tabName = permission. The permission should be configured
+; in permissions.ini (who should see the tab)
+; and permissionBehavior.ini (what should be displayed instead of the tab).
+; Note that this ONLY controls whether or not the tab is displayed; if you wish to
+; restrict actual searching, you will also need to make sure that the relevant
+; controller(s) are blocking access using the same named permission.
+[SearchTabsPermissions]
+;EIT = access.EITModule
+;Primo = access.PrimoModule
+
+; Uncomment portions of this section to label searches from particular sources in the
+; search history display.  Keys are search backend names, values are labels for use in
+; the user interface (subject to translation).
+[SearchHistoryLabels]
+;Solr = Catalog
+;Summon = Summon
+;WorldCat = WorldCat
+;SolrWeb = "Library Website"
+;EDS = "EBSCO Discovery Service"
+
+; Activate Captcha validation on select forms
+; VuFind will use reCaptcha validation to prevent bots from using certain actions of
+; your instance. See http://www.google.com/recaptcha for more information on Captcha
+; and create keys for your domain.
+; You will need to provide a sslcapath in the [Http] section for your Captcha to work.
+[Captcha]
+siteKey  = public
+secretKey = private
+; Valid theme values: dark, light
+theme      = theme
+; Valid forms values: changePassword, email, feedback, newAccount, passwordRecovery,
+;                     sms, userComments
+; Use * for all supported forms
+; Note: when "feedback" is active, Captcha can be conditionally disabled on a
+;       form-by-form basis with the useCaptcha setting in FeedbackForms.yaml.
+;forms = changeEmail, changePassword, email, newAccount, passwordRecovery, sms
+
+
+; This section can be used to display default text inside the search boxes, useful
+; for instructions. Format:
+;
+; backend = Placeholder text
+;
+; You can use a "default" setting if you want a standard string displayed across
+; all backends not otherwise specified. You can qualify backend names with a
+; colon-delimited suffix if you wish to use special placeholders in combination
+; with filtered search tabs (see [SearchTabsFilters] above).
+[SearchPlaceholder]
+;default = "Enter search terms here..."
+;Solr = "Search the catalog"
+;Solr:filtered = "Search the filtered catalog"
+;Summon = "Search Summon"
+
+; This section controls VuFind's social features.
+[Social]
+; Comments may be "enabled" or "disabled" (default = "enabled")
+comments = enabled
+; Favorite lists may be "enabled", "disabled", "public_only" or "private_only"
+; (default = "enabled")
+; The public_only/private_only settings restrict the type of list users may
+; create. If you change this to a more restrictive option, it is your responsibility
+; to update the user_list database table to update the status of existing lists.
+lists = enabled
+; The following two settings are equivalent to default_limit / limit_options in
+; searches.ini, but used to control the page sizes of lists of favorites:
+lists_default_limit   = 20
+;lists_limit_options   = 10,20,40,60,80,100
+; This section controls what happens when a record title in a favorites list
+; is clicked. VuFind can either embed the full result directly in the list using
+; AJAX or can display it at its own separate URL as a full HTML page.
+; See the [List] section of searches.ini for all available options.
+lists_view=full
+; Tags may be "enabled" or "disabled" (default = "enabled")
+; When disabling tags, don't forget to also turn off tag search in searches.ini.
+tags = enabled
+; This controls the maximum length of a single tag; it should correspond with the
+; field size in the tags database table.
+max_tag_length = 64
+; This controls whether tags are case-sensitive (true) or always forced to be
+; represented as lowercase strings (false -- the default).
+case_sensitive_tags = false
+; If this setting is set to false, users will not be presented with a search
+; drop-down or advanced search link when searching/viewing tags. This is recommended
+; when using a multi-backend system (e.g. Solr + Summon + WorldCat). If set to
+; true, the standard Solr search options and advanced search link will be shown
+; in the tag screens; this is recommended when using a Solr-only configuration.
+show_solr_options_in_tag_search = false
diff --git a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/FeedbackTest.php b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/FeedbackTest.php
index 4af18df2b1a6a66a81986b88f9387ac8dc864bd0..5b43464d8a626b17fa3a8aa95cba9fc21a66da19 100644
--- a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/FeedbackTest.php
+++ b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/FeedbackTest.php
@@ -98,14 +98,14 @@ class FeedbackTest extends \VuFindTest\Unit\MinkTestCase
     }
 
     /**
-     * Test that feedback form can be successfully populated and submitted.
+     * Fill in the feedback form.
+     *
+     * @param Element $page Page element
      *
      * @return void
      */
-    public function testFeedbackForm()
+    protected function fillInAndSubmitFeedbackForm($page)
     {
-        // By default, no OpenURL on record page:
-        $page = $this->setupPage();
         $this->clickCss($page, '#feedbackLink');
         $this->snooze();
         $this->findCss($page, '#modal .form-control[name="name"]')->setValue('Me');
@@ -114,6 +114,48 @@ class FeedbackTest extends \VuFindTest\Unit\MinkTestCase
         $this->findCss($page, "#modal #message")->setValue('test test test');
         $this->clickCss($page, '#modal input[type="submit"]');
         $this->snooze();
+    }
+
+    /**
+     * Test that feedback form can be successfully populated and submitted.
+     *
+     * @return void
+     */
+    public function testFeedbackForm()
+    {
+        // By default, no OpenURL on record page:
+        $page = $this->setupPage();
+        $this->fillInAndSubmitFeedbackForm($page);
+        $this->assertEquals(
+            'Thank you for your feedback.',
+            $this->findCss($page, '#modal .alert-success')->getText()
+        );
+    }
+
+    /**
+     * Test that feedback form can be successfully populated and submitted.
+     *
+     * @return void
+     */
+    public function testFeedbackFormWithCaptcha()
+    {
+        // By default, no OpenURL on record page:
+        $page = $this->setupPage(
+            [
+                'Captcha' => ['types' => ['demo'], 'forms' => 'feedback']
+            ]
+        );
+        $this->fillInAndSubmitFeedbackForm($page);
+        // CAPTCHA should have failed...
+        $this->assertEquals(
+            'CAPTCHA not passed',
+            $this->findCss($page, '.modal-body .alert-danger')->getText()
+        );
+        // Now fix the CAPTCHA
+        $this->findCss($page, 'form [name="demo_captcha"]')
+            ->setValue('demo');
+        $this->clickCss($page, '#modal input[type="submit"]');
+        $this->snooze();
         $this->assertEquals(
             'Thank you for your feedback.',
             $this->findCss($page, '#modal .alert-success')->getText()
diff --git a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/RecordActionsTest.php b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/RecordActionsTest.php
index d2d1178a2235347ded6941ed316f514ab301554a..f34d7778567354c8fc3fd9a546e4c7791fcbf077 100644
--- a/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/RecordActionsTest.php
+++ b/module/VuFind/tests/integration-tests/src/VuFindTest/Mink/RecordActionsTest.php
@@ -124,7 +124,6 @@ class RecordActionsTest extends \VuFindTest\Unit\MinkTestCase
         // Create new account
         $this->makeAccount($page, 'username1');
         // Make sure page updated for login
-        // $page = $this->gotoRecord();
         $this->clickCss($page, '.record-tabs .usercomments');
         $this->snooze();
         $this->assertEquals(// Can Comment?
@@ -148,6 +147,73 @@ class RecordActionsTest extends \VuFindTest\Unit\MinkTestCase
         $this->clickCss($page, '.logoutOptions a.logout');
     }
 
+    /**
+     * Test adding comments on records (with Captcha enabled).
+     *
+     * @return void
+     */
+    public function testAddCommentWithCaptcha()
+    {
+        // Set up configs:
+        $this->changeConfigs(
+            [
+                'config' => [
+                    'Captcha' => ['types' => ['demo'], 'forms' => '*']
+                ]
+            ]
+        );
+        // Go to a record view
+        $page = $this->gotoRecord();
+        // Click add comment without logging in
+        // TODO Rewrite for comment and login coming
+        $this->clickCss($page, '.record-tabs .usercomments');
+        $this->snooze();
+        $this->findCss($page, '.comment-form');
+        $this->assertEquals(// Can Comment?
+            'You must be logged in first',
+            $this->findCss($page, 'form.comment-form .btn.btn-primary')->getText()
+        );
+        $this->clickCss($page, 'form.comment-form .btn-primary');
+        $this->snooze();
+        $this->findCss($page, '.modal.in'); // Lightbox open
+        $this->findCss($page, '.modal [name="username"]');
+        // Log in to existing account
+        $this->fillInLoginForm($page, 'username1', 'test');
+        $this->submitLoginForm($page);
+        // Make sure page updated for login
+        $this->clickCss($page, '.record-tabs .usercomments');
+        $this->snooze();
+        $this->assertEquals(// Can Comment?
+            'Add your comment',
+            $this->findCss($page, 'form.comment-form .btn.btn-primary')->getValue()
+        );
+        // "Add" empty comment
+        $this->clickCss($page, 'form.comment-form .btn-primary');
+        $this->snooze();
+        $this->assertNull($page->find('css', '.comment'));
+        // Add comment without CAPTCHA
+        $this->findCss($page, 'form.comment-form [name="comment"]')->setValue('one');
+        $this->clickCss($page, 'form.comment-form .btn-primary');
+        $this->snooze();
+        $this->assertEquals(
+            'CAPTCHA not passed',
+            $this->findCss($page, '.modal-body .alert-danger')->getText()
+        );
+        $this->clickCss($page, '.modal-body button');
+        // Now fix the CAPTCHA
+        $this->findCss($page, 'form.comment-form [name="demo_captcha"]')
+            ->setValue('demo');
+        $this->clickCss($page, 'form.comment-form .btn-primary');
+        $this->snooze();
+        $this->findCss($page, '.comment');
+        // Remove comment
+        $this->clickCss($page, '.comment .delete');
+        $this->snooze(); // wait for UI update
+        $this->assertNull($page->find('css', '.comment'));
+        // Logout
+        $this->clickCss($page, '.logoutOptions a.logout');
+    }
+
     /**
      * Test adding tags on records.
      *
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Captcha/ImageFactoryTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Captcha/ImageFactoryTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..bfd980d49c40e1181fe1f262ecd78b69d874c1c5
--- /dev/null
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Captcha/ImageFactoryTest.php
@@ -0,0 +1,94 @@
+<?php
+
+/**
+ * Unit tests for Image CAPTCHA handler factory.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2020.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFindTest\Captcha;
+
+/**
+ * Unit tests for Image CAPTCHA handler factory.
+ *
+ * @category VuFind
+ * @package  Tests
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+class ImageFactoryTest extends \VuFindTest\Unit\MockContainerTest
+{
+    /**
+     * Test that the factory behaves correctly.
+     *
+     * @return void
+     */
+    public function testFactory()
+    {
+        // Set up mock services expected by factory:
+        $options = new \Laminas\Cache\Storage\Adapter\FilesystemOptions();
+        $storage = $this->container->get(
+            \Laminas\Cache\Storage\StorageInterface::class
+        );
+        $storage->expects($this->once())->method('getOptions')
+            ->will($this->returnValue($options));
+        $cacheManager = $this->container->get(\VuFind\Cache\Manager::class);
+        $cacheManager->expects($this->once())->method('getCache')
+            ->with($this->equalTo('public'))
+            ->will($this->returnValue($storage));
+
+        $url = $this->container->get(\VuFind\View\Helper\Root\Url::class);
+        $manager = $this->container->get('ViewHelperManager');
+        $manager->expects($this->once())->method('get')
+            ->with($this->equalTo('url'))->will($this->returnValue($url));
+
+        $factory = new \VuFind\Captcha\ImageFactory();
+        $fakeImage = new class {
+            /**
+             * Constructor arguments
+             *
+             * @var array
+             */
+            public $constructorArgs;
+
+            /**
+             * Constructor
+             */
+            public function __construct()
+            {
+                $this->constructorArgs = func_get_args();
+            }
+        };
+        $result = $factory($this->container, get_class($fakeImage));
+        $expectedFont = APPLICATION_PATH
+        . '/vendor/webfontkit/open-sans/fonts/opensans-regular.ttf';
+        $this->assertTrue(file_exists($expectedFont));
+        $expected = [
+            'font' => $expectedFont,
+            'imgDir' => '/tmp'
+        ];
+        $this->assertEquals($expected, $result->constructorArgs[0]->getOptions());
+        $this->assertEquals('/cache/', $result->constructorArgs[1]);
+    }
+}
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Config/UpgradeTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Config/UpgradeTest.php
index 105dd246f8537ab25ae911278496c4499ff314e7..4914e862e98d8cc55eff1034a7fa28c3b08f34e7 100644
--- a/module/VuFind/tests/unit-tests/src/VuFindTest/Config/UpgradeTest.php
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Config/UpgradeTest.php
@@ -540,4 +540,21 @@ class UpgradeTest extends \VuFindTest\Unit\TestCase
             )
         );
     }
+
+    /**
+     * Test ReCaptcha setting migration.
+     *
+     * @return void
+     */
+    public function testReCaptcha()
+    {
+        $upgrader = $this->getUpgrader('recaptcha');
+        $upgrader->run();
+        $results = $upgrader->getNewConfigs();
+        $captcha = $results['config.ini']['Captcha'];
+        $this->assertEquals('public', $captcha['recaptcha_siteKey']);
+        $this->assertEquals('private', $captcha['recaptcha_secretKey']);
+        $this->assertEquals('theme', $captcha['recaptcha_theme']);
+        $this->assertEquals(['recaptcha'], $captcha['types']);
+    }
 }
diff --git a/themes/bootstrap3/js/record.js b/themes/bootstrap3/js/record.js
index b9cb2d4792cb9956c27103f539b97d220f2e2130..e130768c48e6b0f1c06698b5d881315f79b178a6 100644
--- a/themes/bootstrap3/js/record.js
+++ b/themes/bootstrap3/js/record.js
@@ -1,4 +1,4 @@
-/*global deparam, getUrlRoot, grecaptcha, recaptchaOnLoad, resetCaptcha, syn_get_widget, userIsLoggedIn, VuFind, setupJumpMenus */
+/*global deparam, getUrlRoot, recaptchaOnLoad, resetCaptcha, syn_get_widget, userIsLoggedIn, VuFind, setupJumpMenus */
 /*exported ajaxTagUpdate, recordDocReady, refreshTagListCallback */
 
 /**
@@ -90,17 +90,11 @@ function registerAjaxCommentRecord(_context) {
     var id = form.id.value;
     var recordSource = form.source.value;
     var url = VuFind.path + '/AJAX/JSON?' + $.param({ method: 'commentRecord' });
-    var data = {
-      comment: form.comment.value,
-      id: id,
-      source: recordSource
-    };
-    if (typeof grecaptcha !== 'undefined') {
-      var recaptcha = $(form).find('.g-recaptcha');
-      if (recaptcha.length > 0) {
-        data['g-recaptcha-response'] = grecaptcha.getResponse(recaptcha.data('captchaId'));
-      }
-    }
+    var data = {};
+    $(form).find("input,textarea").each(function appendCaptchaData() {
+      var input = $(this);
+      data[input.attr('name')] = input.val();
+    });
     $.ajax({
       type: 'POST',
       url: url,
diff --git a/themes/bootstrap3/templates/Auth/Database/recovery.phtml b/themes/bootstrap3/templates/Auth/Database/recovery.phtml
index b9f657abeca3920dbce9ed43d22523da73f56667..4ebe565de0f83a2b5df59289886fed36583991c4 100644
--- a/themes/bootstrap3/templates/Auth/Database/recovery.phtml
+++ b/themes/bootstrap3/templates/Auth/Database/recovery.phtml
@@ -6,7 +6,7 @@
   <label class="control-label"><?=$this->transEsc('recovery_by_email') ?>:</label>
   <input type="email" name="email" class="form-control"/>
 </div>
-<?=$this->recaptcha()->html($this->useRecaptcha) ?>
+<?=$this->captcha()->html($this->useCaptcha) ?>
 <div class="form-group">
   <input class="btn btn-primary" name="submit" type="submit" value="<?=$this->transEsc('Recover Account') ?>"/>
 </div>
diff --git a/themes/bootstrap3/templates/Helpers/captcha.phtml b/themes/bootstrap3/templates/Helpers/captcha.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..3d4ef92d84d4ad55e40370a8a02d9d36e8c06aaa
--- /dev/null
+++ b/themes/bootstrap3/templates/Helpers/captcha.phtml
@@ -0,0 +1,33 @@
+<?php if ($this->wrapHtml):?>
+  <div class="form-group">
+<?php endif;?>
+
+<?php if (count($this->captchas) == 1):?>
+  <label class="control-label"><?=$this->transEsc('captcha_label_single')?></label>
+  <p><?=$this->captcha()->getHtmlForCaptcha($this->captchas[0])?></p>
+<?php else:?>
+  <label class="control-label"><?=$this->transEsc('captcha_label_multiple')?></label>
+  <!-- nav -->
+  <ul class="nav nav-tabs">
+    <?php foreach ($this->captchas as $i => $captcha):?>
+      <?php $active = $i == 0 ? ' class="active"' : ''?>
+      <li<?=$active?>>
+        <a href="#<?=$captcha->getId()?>" role="tab" data-toggle="tab"><?=$this->transEsc('captcha_method_' . strtolower($captcha->getId()), [], $captcha->getId())?></a>
+      </li>
+    <?php endforeach;?>
+  </ul>
+
+  <!-- panes -->
+  <div class="tab-content">
+    <?php foreach ($this->captchas as $i => $captcha):?>
+      <?php $active = $i == 0 ? ' active' : ''?>
+      <div class="tab-pane fade in<?=$active?>" id="<?=$captcha->getId()?>">
+        <?=$this->captcha()->getHtmlForCaptcha($captcha)?>
+      </div>
+    <?php endforeach;?>
+  </div>
+<?php endif;?>
+
+<?php if ($this->wrapHtml):?>
+  </div>
+<?php endif;?>
diff --git a/themes/bootstrap3/templates/Helpers/email-form-fields.phtml b/themes/bootstrap3/templates/Helpers/email-form-fields.phtml
index a672715edc4b2d3fa682d8563a35b67fb06b4ce7..92a702072a77f14eb011653b60dead0c150c4aac 100644
--- a/themes/bootstrap3/templates/Helpers/email-form-fields.phtml
+++ b/themes/bootstrap3/templates/Helpers/email-form-fields.phtml
@@ -34,7 +34,7 @@
     </div>
   </div>
 <?php endif ?>
-<?=$this->recaptcha()->html($this->useRecaptcha) ?>
+<?=$this->captcha()->html($this->useCaptcha) ?>
 <div class="form-group">
   <input type="submit" class="btn btn-primary" name="submit" value="<?=$this->transEsc('Send')?>"/>
 </div>
diff --git a/themes/bootstrap3/templates/RecordTab/usercomments.phtml b/themes/bootstrap3/templates/RecordTab/usercomments.phtml
index f3b2887135c68270a9fe056e46bf58eadd711f1d..7b05122735970c0375fa1e39d9b4d0255c813e94 100644
--- a/themes/bootstrap3/templates/RecordTab/usercomments.phtml
+++ b/themes/bootstrap3/templates/RecordTab/usercomments.phtml
@@ -14,8 +14,8 @@
   <?php if($user): ?>
     <div class="text-form">
       <textarea name="comment" class="form-control" rows="3" required></textarea>
-      <?php if ($this->tab->isRecaptchaActive()): ?>
-        <?=$this->recaptcha()->html(true, false) ?>
+      <?php if ($this->tab->isCaptchaActive()): ?>
+        <?=$this->captcha()->html(true, false) ?>
       <?php endif; ?>
       <input class="btn btn-primary" data-loading-text="<?=$this->transEsc('Submitting') ?>..." type="submit" value="<?=$this->transEsc("Add your comment")?>"/>
     </div>
diff --git a/themes/bootstrap3/templates/feedback/form.phtml b/themes/bootstrap3/templates/feedback/form.phtml
index 78c3eb774dc0e9cff52e34ddcc8bcffea8fb5fcb..5f449abd71a31701a1d66d064af79a7be13a6282 100644
--- a/themes/bootstrap3/templates/feedback/form.phtml
+++ b/themes/bootstrap3/templates/feedback/form.phtml
@@ -95,7 +95,7 @@
           <?=$helpPost?>
         </div>
       <?php endif ?>
-      <?=$this->recaptcha()->html($this->useRecaptcha) ?>
+      <?=$this->captcha()->html($this->useCaptcha) ?>
     <?php endif ?>
     <?= $this->formRow($formElement) ?>
     <?php if (!empty($elementHelpPost)): ?>
diff --git a/themes/bootstrap3/templates/layout/layout.phtml b/themes/bootstrap3/templates/layout/layout.phtml
index 82f0ba69d84f7fc2b9c122844012c243e0a9d858..ffacf7b573b40aef2e3d9fd581a258d88e8366d9 100644
--- a/themes/bootstrap3/templates/layout/layout.phtml
+++ b/themes/bootstrap3/templates/layout/layout.phtml
@@ -202,8 +202,8 @@ JS;
     <div class="offcanvas-overlay" data-toggle="offcanvas"></div>
     <?=$this->googleanalytics()?>
     <?=$this->piwik()?>
-    <?php if ($this->recaptcha()->active()): ?>
-      <?=$this->inlineScript(\Laminas\View\Helper\HeadScript::FILE, "https://www.google.com/recaptcha/api.js?onload=recaptchaOnLoad&render=explicit&hl=" . $this->layout()->userLang, 'SET')?>
-    <?php endif; ?>
+    <?php foreach ($this->captcha()->js() as $jsInclude):?>
+      <?=$this->inlineScript(\Laminas\View\Helper\HeadScript::FILE, $jsInclude, 'SET')?>
+    <?php endforeach; ?>
   </body>
 </html>
diff --git a/themes/bootstrap3/templates/myresearch/account.phtml b/themes/bootstrap3/templates/myresearch/account.phtml
index 5137e04158411607c4e0ab573ca2deea546d40bf..6a687ac5cea00c2c969ada90e9ec9510500a5192 100644
--- a/themes/bootstrap3/templates/myresearch/account.phtml
+++ b/themes/bootstrap3/templates/myresearch/account.phtml
@@ -10,7 +10,7 @@
 
 <form method="post" name="accountForm" id="accountForm" class="form-user-create" data-toggle="validator" role="form">
   <?=$this->auth()->getCreateFields()?>
-  <?=$this->recaptcha()->html($this->useRecaptcha) ?>
+  <?=$this->captcha()->html($this->useCaptcha) ?>
   <div class="form-group">
     <a class="back-to-login btn btn-link" href="<?=$this->url('myresearch-userlogin') ?>"><i class="fa fa-chevron-left" aria-hidden="true"></i> <?=$this->transEsc('Back')?></a>
     <input class="btn btn-primary" type="submit" name="submit" value="<?=$this->transEsc('Submit')?>"/>
diff --git a/themes/bootstrap3/templates/myresearch/changeemail.phtml b/themes/bootstrap3/templates/myresearch/changeemail.phtml
index 356699c90a298e0479f0002d7009d822ffa1074c..20edf75a18f670982d72b0887c7f5e3bdbdc35f0 100644
--- a/themes/bootstrap3/templates/myresearch/changeemail.phtml
+++ b/themes/bootstrap3/templates/myresearch/changeemail.phtml
@@ -20,7 +20,7 @@
         <label class="control-label"><?=$this->transEsc('Email Address') ?>:</label>
         <input type="text" name="email" class="form-control" value="<?=$this->escapeHtmlAttr($this->email)?>" />
       </div>
-      <?=$this->recaptcha()->html($this->useRecaptcha) ?>
+      <?=$this->captcha()->html($this->useCaptcha) ?>
       <div class="form-group">
         <input class="btn btn-primary" name="submit" type="submit" value="<?=$this->transEsc('Submit')?>" />
       </div>
diff --git a/themes/bootstrap3/templates/myresearch/newpassword.phtml b/themes/bootstrap3/templates/myresearch/newpassword.phtml
index 5ccd51db90ebc06aaa3019ff38a3a82ed36aa3a1..08e38e87fe18ccd40b2a5307d572195eabec4418 100644
--- a/themes/bootstrap3/templates/myresearch/newpassword.phtml
+++ b/themes/bootstrap3/templates/myresearch/newpassword.phtml
@@ -24,7 +24,7 @@
     <input type="hidden" value="<?=$this->escapeHtmlAttr($this->username) ?>" name="username"/>
     <input type="hidden" value="<?=$this->escapeHtmlAttr($this->auth_method) ?>" name="auth_method"/>
     <?=$this->auth()->getNewPasswordForm() ?>
-    <?=$this->recaptcha()->html($this->useRecaptcha) ?>
+    <?=$this->captcha()->html($this->useCaptcha) ?>
     <div class="form-group">
       <input class="btn btn-primary" name="submit" type="submit" value="<?=$this->transEsc('Submit')?>" />
     </div>
diff --git a/themes/bootstrap3/templates/record/sms.phtml b/themes/bootstrap3/templates/record/sms.phtml
index e06e136445478fd3985e2eeda005f2c6f4cef939..ce25cc1b1b4b61670be0f8196da3bec299a2f9f8 100644
--- a/themes/bootstrap3/templates/record/sms.phtml
+++ b/themes/bootstrap3/templates/record/sms.phtml
@@ -39,7 +39,7 @@ JS;
     <?php $keys = is_array($this->carriers) ? array_keys($this->carriers) : []; ?>
     <input type="hidden" name="provider" value="<?=$keys[0] ?? ''?>" />
   <?php endif; ?>
-  <?=$this->recaptcha()->html($this->useRecaptcha) ?>
+  <?=$this->captcha()->html($this->useCaptcha) ?>
   <div class="form-group">
     <input class="btn btn-primary" type="submit" name="submit" value="<?=$this->transEsc('Send Text')?>"/>
   </div>
diff --git a/themes/bootstrap3/theme.config.php b/themes/bootstrap3/theme.config.php
index 5b90a4fe2654718c92608b41dd210320ddf20d8a..1713636c60defbc2b739f00366d832141b987ee8 100644
--- a/themes/bootstrap3/theme.config.php
+++ b/themes/bootstrap3/theme.config.php
@@ -30,14 +30,12 @@ return [
             'VuFind\View\Helper\Bootstrap3\Flashmessages' => 'VuFind\View\Helper\Root\FlashmessagesFactory',
             'VuFind\View\Helper\Bootstrap3\Highlight' => 'Laminas\ServiceManager\Factory\InvokableFactory',
             'VuFind\View\Helper\Bootstrap3\LayoutClass' => 'VuFind\View\Helper\Bootstrap3\LayoutClassFactory',
-            'VuFind\View\Helper\Bootstrap3\Recaptcha' => 'VuFind\View\Helper\Root\RecaptchaFactory',
             'VuFind\View\Helper\Bootstrap3\Search' => 'Laminas\ServiceManager\Factory\InvokableFactory',
         ],
         'aliases' => [
             'flashmessages' => 'VuFind\View\Helper\Bootstrap3\Flashmessages',
             'highlight' => 'VuFind\View\Helper\Bootstrap3\Highlight',
             'layoutClass' => 'VuFind\View\Helper\Bootstrap3\LayoutClass',
-            'recaptcha' => 'VuFind\View\Helper\Bootstrap3\Recaptcha',
             'search' => 'VuFind\View\Helper\Bootstrap3\Search'
         ]
     ]
diff --git a/themes/root/templates/Captcha/demo.phtml b/themes/root/templates/Captcha/demo.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..c9d6a6300b1855c4c88e40124332283c40886c49
--- /dev/null
+++ b/themes/root/templates/Captcha/demo.phtml
@@ -0,0 +1 @@
+<p><label>Demo CAPTCHA -- FOR TESTING ONLY; enter "demo" into this box: <input name="demo_captcha"></label></p>
diff --git a/themes/root/templates/Captcha/figlet.phtml b/themes/root/templates/Captcha/figlet.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..6885b0ba94a016505c533268f7c78a4df6cdc2b0
--- /dev/null
+++ b/themes/root/templates/Captcha/figlet.phtml
@@ -0,0 +1,7 @@
+<?php
+  $laminasCaptcha = $this->captcha->getCaptcha();
+  $id = $laminasCaptcha->generate();
+?>
+<pre><?=$laminasCaptcha->getFiglet()->render($laminasCaptcha->getWord())?></pre>
+<p><label for="<?=$this->captcha->getHtmlInputId()?>"><?=$this->transEsc('captcha_label_input')?></label> <input name="<?=$this->captcha->getHtmlInputId()?>" id="<?=$this->captcha->getHtmlInputId()?>"></p>
+<input type="hidden" name="<?=$this->captcha->getHtmlInternalId()?>" value="<?=$id?>">
diff --git a/themes/root/templates/Captcha/image.phtml b/themes/root/templates/Captcha/image.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..58027eea4f1b304b8d6fc939c3b0822a7561c06e
--- /dev/null
+++ b/themes/root/templates/Captcha/image.phtml
@@ -0,0 +1,8 @@
+<?php
+  $laminasCaptcha = $this->captcha->getCaptcha();
+  $id = $laminasCaptcha->generate();
+?>
+<img src="<?=$this->captcha->getCacheBasePath() . $id . $laminasCaptcha->getSuffix();?>">
+<br/><br/>
+<p><label for="<?=$this->captcha->getHtmlInputId()?>"><?=$this->transEsc('captcha_label_input')?></label> <input name="<?=$this->captcha->getHtmlInputId()?>" id="<?=$this->captcha->getHtmlInputId()?>"></p>
+<input type="hidden" name="<?=$this->captcha->getHtmlInternalId()?>" value="<?=$id?>">
diff --git a/themes/root/templates/Captcha/recaptcha.phtml b/themes/root/templates/Captcha/recaptcha.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..dbf8cb1c0fecb4dd342be7f46dc9b60b1a708bd5
--- /dev/null
+++ b/themes/root/templates/Captcha/recaptcha.phtml
@@ -0,0 +1 @@
+<?=$this->captcha->getHtml()?>
\ No newline at end of file
diff --git a/themes/root/theme.config.php b/themes/root/theme.config.php
index 95077af0969305888ed02349129432f8ebce9c95..521a62963733b1d59980774a9e95721a55766a40 100644
--- a/themes/root/theme.config.php
+++ b/themes/root/theme.config.php
@@ -10,6 +10,7 @@ return [
             'VuFind\View\Helper\Root\Auth' => 'VuFind\View\Helper\Root\AuthFactory',
             'VuFind\View\Helper\Root\AuthorNotes' => 'VuFind\View\Helper\Root\ContentLoaderFactory',
             'VuFind\View\Helper\Root\Browse' => 'Laminas\ServiceManager\Factory\InvokableFactory',
+            'VuFind\View\Helper\Root\Captcha' => 'VuFind\View\Helper\Root\CaptchaFactory',
             'VuFind\View\Helper\Root\Cart' => 'VuFind\View\Helper\Root\CartFactory',
             'VuFind\View\Helper\Root\Citation' => 'VuFind\View\Helper\Root\CitationFactory',
             'VuFind\View\Helper\Root\Config' => 'VuFind\View\Helper\Root\ConfigFactory',
@@ -40,7 +41,6 @@ return [
             'VuFind\View\Helper\Root\Piwik' => 'VuFind\View\Helper\Root\PiwikFactory',
             'VuFind\View\Helper\Root\Printms' => 'Laminas\ServiceManager\Factory\InvokableFactory',
             'VuFind\View\Helper\Root\ProxyUrl' => 'VuFind\View\Helper\Root\ProxyUrlFactory',
-            'VuFind\View\Helper\Root\Recaptcha' => 'VuFind\View\Helper\Root\RecaptchaFactory',
             'VuFind\View\Helper\Root\Recommend' => 'Laminas\ServiceManager\Factory\InvokableFactory',
             'VuFind\View\Helper\Root\Record' => 'VuFind\View\Helper\Root\RecordFactory',
             'VuFind\View\Helper\Root\RecordDataFormatter' => 'VuFind\View\Helper\Root\RecordDataFormatterFactory',
@@ -78,6 +78,7 @@ return [
             'auth' => 'VuFind\View\Helper\Root\Auth',
             'authorNotes' => 'VuFind\View\Helper\Root\AuthorNotes',
             'browse' => 'VuFind\View\Helper\Root\Browse',
+            'captcha' => 'VuFind\View\Helper\Root\Captcha',
             'cart' => 'VuFind\View\Helper\Root\Cart',
             'citation' => 'VuFind\View\Helper\Root\Citation',
             'config' => 'VuFind\View\Helper\Root\Config',
@@ -108,7 +109,6 @@ return [
             'piwik' => 'VuFind\View\Helper\Root\Piwik',
             'printms' => 'VuFind\View\Helper\Root\Printms',
             'proxyUrl' => 'VuFind\View\Helper\Root\ProxyUrl',
-            'recaptcha' => 'VuFind\View\Helper\Root\Recaptcha',
             'recommend' => 'VuFind\View\Helper\Root\Recommend',
             'record' => 'VuFind\View\Helper\Root\Record',
             'recordDataFormatter' => 'VuFind\View\Helper\Root\RecordDataFormatter',