diff --git a/server/app.py b/server/app.py
index abaa15bd2ae3a1c51b857c82c9ec8a4755282b18..88ee923a826c08c676751a43a1604e3713c2705b 100644
--- a/server/app.py
+++ b/server/app.py
@@ -9,7 +9,7 @@ from datetime import timedelta
 from dateutil.tz import tzlocal
 
 from .staticVariables import TEMPLATE_DIR, STATIC_DIR, DATA_DIR, DEFAULT_STOP, DEFAULT_HOST, DEFAULT_PORT, APP_VERSION, AVAILABLE_LANGUAGES, TRANSLATION_DIR, GOOGLE_MAPS_KEY, GOOGLE_MAPS_TEST_KEY, GOOGLE_MAPS_REGION
-from .dataHandler import loadJson, hasTemplate, hasData, isDirectory, CustomJsonEncoder
+from .dataHandler import loadJson, hasTemplate, hasData, isDirectory, CustomJsonEncoder, generateToken
 from .gtfs import GtfsHandler, GtfsConstants
 from .locator import FoliLocator
 
@@ -91,11 +91,25 @@ def afterRequestHandler(response):
 	response.headers['Server'] = APP_VERSION
 	response.headers['X-Frame-Options'] = 'DENY'
 	response.headers['X-XSS-Protection'] = '1; mode=block'
-	response.headers['Content-Security-Policy'] = "default-src='self'"
+
+	# include a strict'ish list of dependencies, deliberately don't allow media sources such as youtube, this allows safer use of the twitter widget (assumes modern browser)
+	cspSrcWhitelist = "*.twitter.com *.twimg.com *.googleapis.com script.google.com script.googleusercontent.com fonts.gstatic.com maxcdn.bootstrapcdn.com cdnjs.cloudflare.com code.jquery.com www.feedrapp.info weatherwidget.io forecast7.com"
+	if not getattr(g, 'is_kiosk', False):
+		cspSrcWhitelist += " *.youtube.com"
+
+	cspPolicy = "default-src 'self' 'unsafe-inline' 'unsafe-eval' {whitelist}; img-src http: https: data:; base-uri 'none'; object-src 'none'; script-src 'self' 'nonce-{nonce}' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' {whitelist}".format(nonce = g.csp_nonce, whitelist = cspSrcWhitelist)
+	if getattr(g, 'is_kiosk', False):
+		cspPolicy += "; media-src 'none'"
+	else:
+		cspPolicy += "; media-src *"
+
+	response.headers['Content-Security-Policy'] = cspPolicy
 	return response
 
 @app.url_value_preprocessor
 def pullUrlGlobals(endpoint, values):
+	g.csp_nonce = generateToken(16)
+
 	if endpoint == None  or values == None or endpoint == 'static' or endpoint.startswith('root/'):
 		return
 
diff --git a/server/dataHandler.py b/server/dataHandler.py
index d06edf8767cb7fda8dd0f130a8866264f31d529e..2c7b67156e11b3ee04416b71809fc3bbf7c059ef 100644
--- a/server/dataHandler.py
+++ b/server/dataHandler.py
@@ -1,6 +1,6 @@
 # coding=utf-8
 
-import os, json, sys
+import os, json, sys, base64
 
 from flask.json import JSONEncoder
 from datetime import date
@@ -61,3 +61,7 @@ def hasTemplate(filename):
 def hasData(filename):
 	path = os.path.join(DATA_DIR, filename)
 	return not os.path.islink(path) and os.path.isfile(os.path.normpath(path))
+
+# python token_urlsafe backported
+def generateToken(bytes):
+	return base64.urlsafe_b64encode(os.urandom(bytes)).rstrip(b'=').decode('ascii')
diff --git a/templates/feedback/index.html b/templates/feedback/index.html
index 8faf347052e3064bab8dd3cfc52045e47c515ad9..eeb2556e290d69642025a8dacc4d08d8589151c1 100644
--- a/templates/feedback/index.html
+++ b/templates/feedback/index.html
@@ -204,10 +204,10 @@
 {% block scripts %}
 	{{- super() }}
 
-	<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js"
-	integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
+	<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8="
+	crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script>
 
-	<script>
+	<script nonce="{{ g.csp_nonce }}">
 		$(document).ready(function () {
 			// this may require a GET request, because of browser security rules...
 			// POST would be ideal, however, since we are only sending numeric values it doesn't really matter
diff --git a/templates/foli/app.html b/templates/foli/app.html
index 19d30be885b3e36da52a6522ab827da4dee806ea..4491c79be12652fa3fbb5b6daeb29adf2e796f41 100644
--- a/templates/foli/app.html
+++ b/templates/foli/app.html
@@ -15,7 +15,7 @@
 		<div class="card-header">
 			<h3 class="card-title">Bussilippu kännykällä</h3>
 		</div>
-		<div class="row" style="margin-top: 25px;">
+		<div class="row" class="mt-3">
 			<div class="col-sm-5">
 				<img class="app-image" src="{{ util.static_url('img/mobiililippu_1.png') }}">
 			</div>
diff --git a/templates/foli/index.html b/templates/foli/index.html
index be6aeadb32ce6f486ad04e924b47d5d53bf3b7c1..950d0d8917ec6b8488e928ccd3cd22eff2e63f3a 100644
--- a/templates/foli/index.html
+++ b/templates/foli/index.html
@@ -13,7 +13,7 @@
 
 	<div class="card foli-card box-shadow content-spaced">
         <div class="card-body">
-            <p style="margin: 0;">
+            <p class="m-0">
                 Bussissa voit ostaa kertalipun tai ladata matkakortin käteisellä. Samalla lipulla tai matkakortilla
                 on vaihtoaikaa 2 tuntia.
             </p>
diff --git a/templates/index.html b/templates/index.html
index fbbf25f4758ab70172f608fb7b3ea2a04a1c3c8b..e6f7b8933a44badc12c5a9cb2c601b2951f29169 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -8,7 +8,7 @@
 {% block scripts %}
 	{{- super() }}
 
-	<script>
+	<script nonce="{{ g.csp_nonce }}">
 		$(function () {
 			$('.menu-card').clickableBox();
 		});
diff --git a/templates/info.html b/templates/info.html
index 6a65a3f84772b65a037a3a13f6fceaeec24ba138..7c8f4b77a62df2af299f684d274da2c798d9ef99 100644
--- a/templates/info.html
+++ b/templates/info.html
@@ -6,7 +6,11 @@
 {% set content_lang = 'fi' %}
 
 {% block head %}
+    <meta name="twitter:widgets:csp" content="on">
+    <meta name="twitter:widgets:autoload" content="off">
+
     {{- super() }}
+
     <style>
         .h-auto { height: auto; }
         .info-card > #visitTurku { overflow-y: scroll; }
@@ -17,7 +21,7 @@
 		   but kiosk mode need not be responsive, since the screen resolution is fixed */
         .iframe-container {
             width: 100%;
-            height: 100px;
+            height: 120px;
             position: relative;
         }
         .iframe-area, .iframe-cover {
@@ -55,7 +59,7 @@
                     <div class="iframe-area">
                         <div id="forecast7">
                             <a class="weatherwidget-io" href="https://forecast7.com/en/60d4522d27/turku/" data-label_1="TURKU" data-label_2="Sää" data-theme="original">TURKU Sää</a>
-                            <script>
+                            <script nonce="{{ g.csp_nonce }}">
                                 !function (d, s, id) { var js, fjs = d.getElementsByTagName(s)[0]; if (!d.getElementById(id)) { js = d.createElement(s); js.id = id; js.src = 'https://weatherwidget.io/js/widget.min.js'; fjs.parentNode.insertBefore(js, fjs); } }(document, 'script', 'weatherwidget-io-js');
                             </script>
                         </div>
@@ -96,9 +100,9 @@
     {{- super() }}
 
     <!-- TODO: once the cdnjs project hosts this, migrate to that -->
-    <script src="{{ util.static_url('js/jquery-rss/3.3.0/jquery.rss.min.js') }}"></script>
+    <script src="{{ util.static_url('js/jquery-rss/3.3.0/jquery.rss.min.js') }}" nonce="{{ g.csp_nonce }}"></script>
 
-    <script>
+    <script nonce="{{ g.csp_nonce }}">
         $(document).ready(function () {
             // Twitter initialization code
             window.twttr = (function (d, s, id) {
@@ -139,7 +143,11 @@
             $('#twitter').disableExternalLinks(); // in case the widget fails to load disable the link in the container as a placeholder
             $('small.source-link').disableExternalLinks();
 
+            // Note: disableExternalLinks() is not actually enough here, youtube videos and other embedded media are beyond its reach
+            // HTML5 sandboxing on the iframe may help, but it is more likely just to break the widget...
             twttr.ready(function (twttr) {
+                twttr.widgets.load(document.getElementById("twitter"));
+
                 twttr.events.bind('loaded', function (e) {
                     e.widgets.forEach(function (w) {
                         // the twitter widget is loaded in an iframe, contents() is needed because of that
diff --git a/templates/internal/base.html b/templates/internal/base.html
index fdebc8cc49aabc9fa44a214a92f5af83fe2e1263..f49c0fdfe254890bed0c73d8669ce0242ec19904 100644
--- a/templates/internal/base.html
+++ b/templates/internal/base.html
@@ -139,7 +139,7 @@
 							</div>
 						</div>
 
-						<small class="text-muted">{{ _("This application aggregates information from several sources, using open data or third party services, we take no responsibility on the accuracy of any such information authored by those third parties.") }}<small>
+						<small class="text-muted">{{ _("This application aggregates information from several sources, using open data or third party services, we take no responsibility on the accuracy of any such information authored by those third parties.") }}</small>
 					</div>
 					<div class="modal-footer">
 						<button type="button" class="btn btn-primary" data-dismiss="modal">{{ _("Close") }}</button>
@@ -179,21 +179,21 @@
 		<!-- Optional JavaScript -->
 		<!-- jQuery first, then Popper.js, then Bootstrap JS -->
 		<script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" 
-		crossorigin="anonymous"></script>
+		crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script>
 		<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" 
-		crossorigin="anonymous"></script>
+		crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script>
 		<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" 
-		crossorigin="anonymous"></script>
+		crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script>
 		<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.qrcode/1.0/jquery.qrcode.min.js" integrity="sha256-9MzwK2kJKBmsJFdccXoIDDtsbWFh8bjYK/C7UjB1Ay0=" 
-		crossorigin="anonymous"></script>
+		crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script>
 		<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.20.1/moment-with-locales.min.js" integrity="sha256-XWrGUqSiENmD8bL+BVeLl7iCfhs+pkPyIqrZQcS2Te8=" 
-		crossorigin="anonymous"></script>
+		crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script>
 		{% if stop_info.stop_timezone %}
 		<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.14/moment-timezone-with-data.min.js" integrity="sha256-FJZOELgwnfQRdG8KZUSWCYgucECDf4w5kfQdQSGbVpI=" 
-		crossorigin="anonymous"></script>
+		crossorigin="anonymous" nonce="{{ g.csp_nonce }}"></script>
 		{% endif %}
 
-		<script>
+		<script nonce="{{ g.csp_nonce }}">
 			// quick and dirty jquery "plugin" for making block level elements clickable by using a link they contain
 			// TODO: this needs some serious refinement once we know how we implement changing of the views so that the whole page doesn't need to reload
 			// FIXME: does not chain to previous click handler if it is already set!
@@ -214,6 +214,8 @@
 
 				var handler = function (e) {
 					e.preventDefault();
+					e.stopPropagation();
+
 					$link = $(this);
 					$modal = $('#externalLinkModal');
 
@@ -227,10 +229,10 @@
 				};
 
 				// the two slashes are for the benefit of urls that contain refering URI in the query string
-				this.on('click', 'a[href^="http"]:not([href*="//{{ request.host }}"])', handler);
+				this.on('click', 'a[href^="http"]:not([href*="https://{{ request.host }}"])', handler);
 				// disable links for foreign protocols, just in case
-				this.on('click', 'a[href^="mailto:"]', function(e) { e.preventDefault(); });
-				this.on('click', 'a[href*="://"]:not([href^="http"])', function(e) { e.preventDefault(); });
+				this.on('click', 'a[href^="mailto:"]', function(e) { e.preventDefault(); e.stopPropagation(); });
+				this.on('click', 'a[href*="://"]:not([href^="http"])', function(e) { e.preventDefault(); e.stopPropagation(); });
 
 				// avoid duplicate calls (see info page, twitter widget)
 				this.data('links-disabled', true);
@@ -258,16 +260,18 @@
 
 				// clock implementation follows
 				$clock = $('#headerClock');
-				var clockFormat = 'H:mm';
 				var timeOffset = moment(new Date($clock.data('server-time'))).diff(moment());
 
 				var currentTime;
 				var timerHandler = function() {
 					currentTime = moment().add(timeOffset);
-					$clock.html(currentTime{% if stop_info.stop_timezone %}.tz('{{ stop_info.stop_timezone }}'){% endif %}.format(clockFormat));
+					newTime = currentTime{% if stop_info.stop_timezone %}.tz('{{ stop_info.stop_timezone }}'){% endif %}.format('H:mm');
+
+					if (newTime !== $clock.html())
+						$clock.html(newTime);
 
-					// run on next second wrap
-					setTimeout(timerHandler, (1000 - (new Date().getTime() % 1000)));
+					// run on next 5 second wrap to reduce unnecessary cycles
+					setTimeout(timerHandler, (5000 - (new Date().getTime() % 5000)));
 				};
 
 				// start the clock
diff --git a/templates/map.html b/templates/map.html
index e4084d995a26bb19a1807e738487389b97d962f4..c7c2e9383daf1d011c7ab5cc25035d90a8a1759d 100644
--- a/templates/map.html
+++ b/templates/map.html
@@ -6,6 +6,7 @@
 
 {% block head %}
 	{{- super() }}
+
 	<style>
 		#map-canvas, #map-canvas > div { border-radius: 0.25rem; }
 	</style>
@@ -68,13 +69,14 @@
 
 {% block scripts %}
 	{{- super() }}
-	<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-easing/1.4.1/jquery.easing.min.js"></script>
-	<script src="https://maps.googleapis.com/maps/api/js?key={{ gmaps_key }}&amp;region={{ gmaps_region }}&amp;language={{ g.lang_code }}"></script>
+
+	<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-easing/1.4.1/jquery.easing.min.js" nonce="{{ g.csp_nonce }}"></script>
+	<script src="https://maps.googleapis.com/maps/api/js?key={{ gmaps_key }}&amp;region={{ gmaps_region }}&amp;language={{ g.lang_code }}" nonce="{{ g.csp_nonce }}"></script>
 	<!-- this is used to animate the movement of the buses on the map -->
-	<script src="https://cdnjs.cloudflare.com/ajax/libs/marker-animate-unobtrusive/0.2.8/vendor/markerAnimate.js"></script>
-	<script src="https://cdnjs.cloudflare.com/ajax/libs/marker-animate-unobtrusive/0.2.8/SlidingMarker.min.js"></script>
+	<script src="https://cdnjs.cloudflare.com/ajax/libs/marker-animate-unobtrusive/0.2.8/vendor/markerAnimate.js" nonce="{{ g.csp_nonce }}"></script>
+	<script src="https://cdnjs.cloudflare.com/ajax/libs/marker-animate-unobtrusive/0.2.8/SlidingMarker.min.js" nonce="{{ g.csp_nonce }}"></script>
 
-	<script>
+	<script nonce="{{ g.csp_nonce }}">
 		var gmarkers, gmap = false;
 
 		// focusedMarker is the title of the focused marker