diff --git a/builder/pwnagotchi.yml b/builder/pwnagotchi.yml
index 0164502..f369014 100644
--- a/builder/pwnagotchi.yml
+++ b/builder/pwnagotchi.yml
@@ -99,6 +99,9 @@
           - bc
           - fonts-freefont-ttf
           - fbi
+          - python3-flask
+          - python3-flask-cors
+          - python3-flaskext.wtf
 
   tasks:
   - name: change hostname
diff --git a/pwnagotchi/plugins/default/example.py b/pwnagotchi/plugins/default/example.py
index 3aa5c7d..5a13651 100644
--- a/pwnagotchi/plugins/default/example.py
+++ b/pwnagotchi/plugins/default/example.py
@@ -15,6 +15,11 @@ class Example(plugins.Plugin):
     def __init__(self):
         logging.debug("example plugin created")
 
+    # called when http://<host>:<port>/plugins/<plugin>/ is called
+    # must return a response
+    def on_webhook(self, path, args, req_method):
+        pass
+
     # called when the plugin is loaded
     def on_loaded(self):
         logging.warning("WARNING: this plugin should be disabled! options = " % self.options)
diff --git a/pwnagotchi/ui/web.py b/pwnagotchi/ui/web.py
index 3f1dc48..ee56f9d 100644
--- a/pwnagotchi/ui/web.py
+++ b/pwnagotchi/ui/web.py
@@ -1,6 +1,6 @@
 import re
 import _thread
-from http.server import BaseHTTPRequestHandler, HTTPServer
+import secrets
 from threading import Lock
 import shutil
 import logging
@@ -8,6 +8,13 @@ import logging
 import pwnagotchi
 from pwnagotchi.agent import Agent
 from pwnagotchi import plugins
+from flask import Flask
+from flask import send_file
+from flask import request
+from flask import abort
+from flask import render_template_string
+from flask_cors import CORS
+from flask_wtf.csrf import CSRFProtect
 
 frame_path = '/root/pwnagotchi.png'
 frame_format = 'PNG'
@@ -65,9 +72,11 @@ INDEX = """<html>
         <hr/>
         <form style="display:inline;" method="POST" action="/shutdown" onsubmit="return confirm('This will halt the unit, continue?');">
             <input style="display:inline;" type="submit" class="block" value="Shutdown"/>
+            <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
         </form>
         <form style="display:inline;" method="POST" action="/restart" onsubmit="return confirm('This will restart the service in %s mode, continue?');">
             <input style="display:inline;" type="submit" class="block" value="Restart in %s mode"/>
+            <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
         </form>
     </div>
 
@@ -88,141 +97,87 @@ STATUS_PAGE = """<html>
 </html>"""
 
 
-class Handler(BaseHTTPRequestHandler):
-    AllowedOrigin = None  # CORS headers are not sent
+class RequestHandler:
+    def __init__(self, app):
+        self._app = app
+        self._app.add_url_rule('/', 'index', self.index)
+        self._app.add_url_rule('/ui', 'ui', self.ui)
+        self._app.add_url_rule('/shutdown', 'shutdown', self.shutdown, methods=['POST'])
+        self._app.add_url_rule('/restart', 'restart', self.restart, methods=['POST'])
+        # plugins
+        self._app.add_url_rule('/plugins', 'plugins', self.plugins, strict_slashes=False, defaults={'name': None, 'subpath': None})
+        self._app.add_url_rule('/plugins/<name>', 'plugins', self.plugins, strict_slashes=False, methods=['GET','POST'], defaults={'subpath': None})
+        self._app.add_url_rule('/plugins/<name>/<path:subpath>', 'plugins', self.plugins, methods=['GET','POST'])
 
-    # suppress internal logging
-    def log_message(self, format, *args):
-        return
 
-    def _send_cors_headers(self):
-        # misc security
-        self.send_header("X-Frame-Options", "DENY")
-        self.send_header("X-Content-Type-Options", "nosniff")
-        self.send_header("X-XSS-Protection", "1; mode=block")
-        self.send_header("Referrer-Policy", "same-origin")
-        # cors
-        if Handler.AllowedOrigin:
-            self.send_header("Access-Control-Allow-Origin", Handler.AllowedOrigin)
-            self.send_header('Access-Control-Allow-Credentials', 'true')
-            self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
-            self.send_header("Access-Control-Allow-Headers",
-                             "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
-            self.send_header("Vary", "Origin")
-
-    # just render some html in a 200 response
-    def _html(self, html):
-        self.send_response(200)
-        self._send_cors_headers()
-        self.send_header('Content-type', 'text/html')
-        self.end_headers()
-        try:
-            self.wfile.write(bytes(html, "utf8"))
-        except:
-            pass
-
-    # serve the main html page
-    def _index(self):
+    def index(self):
         other_mode = 'AUTO' if Agent.INSTANCE.mode == 'manual' else 'MANU'
-        self._html(INDEX % (
+        return render_template_string(INDEX % (
             pwnagotchi.name(),
             other_mode,
             other_mode,
             1000))
 
+    def plugins(self, name, subpath):
+        if name is None:
+            # show plugins overview
+            abort(404)
+        else:
+
+            # call plugin on_webhook
+            arguments = request.args
+            req_method = request.method
+
+            # need to return something here
+            if name in plugins.loaded and hasattr(plugins.loaded[name], 'on_webhook'):
+                return render_template_string(plugins.loaded[name].on_webhook(subpath, args=arguments, req_method=req_method))
+
+            abort(500)
+
+
     # serve a message and shuts down the unit
-    def _shutdown(self):
-        self._html(STATUS_PAGE % (pwnagotchi.name(), 'Shutting down ...'))
+    def shutdown(self):
         pwnagotchi.shutdown()
+        return render_template_string(STATUS_PAGE % (pwnagotchi.name(), 'Shutting down ...'))
 
     # serve a message and restart the unit in the other mode
-    def _restart(self):
+    def restart(self):
         other_mode = 'AUTO' if Agent.INSTANCE.mode == 'manual' else 'MANU'
-        self._html(STATUS_PAGE % (pwnagotchi.name(), 'Restart in %s mode ...' % other_mode))
         pwnagotchi.restart(other_mode)
+        return render_template_string(STATUS_PAGE % (pwnagotchi.name(), 'Restart in %s mode ...' % other_mode))
 
     # serve the PNG file with the display image
-    def _image(self):
-        global frame_lock, frame_path, frame_ctype
+    def ui(self):
+        global frame_lock, frame_path
 
         with frame_lock:
-            self.send_response(200)
-            self._send_cors_headers()
-            self.send_header('Content-type', frame_ctype)
-            self.end_headers()
-            try:
-                with open(frame_path, 'rb') as fp:
-                    shutil.copyfileobj(fp, self.wfile)
-            except:
-                pass
-
-    # check the Origin header vs CORS
-    def _is_allowed(self):
-        if not Handler.AllowedOrigin or Handler.AllowedOrigin == '*':
-            return True
-
-        # TODO: FIX doesn't work with GET requests same-origin
-        origin = self.headers.get('origin')
-        if not origin:
-            logging.warning("request with no Origin header from %s" % self.address_string())
-            return False
-
-        if origin != Handler.AllowedOrigin:
-            logging.warning("request with blocked Origin from %s: %s" % (self.address_string(), origin))
-            return False
-
-        return True
-
-    def do_OPTIONS(self):
-        self.send_response(200)
-        self._send_cors_headers()
-        self.end_headers()
-
-    def do_POST(self):
-        if not self._is_allowed():
-            return
-        elif self.path.startswith('/shutdown'):
-            self._shutdown()
-        elif self.path.startswith('/restart'):
-            self._restart()
-        else:
-            self.send_response(404)
-
-    def do_GET(self):
-        if not self._is_allowed():
-            return
-        elif self.path == '/':
-            self._index()
-        elif self.path.startswith('/ui'):
-            self._image()
-        elif self.path.startswith('/plugins'):
-            matches = re.match(r'\/plugins\/([^\/]+)(\/.*)?', self.path)
-            if matches:
-                groups = matches.groups()
-                plugin_name = groups[0]
-                right_path = groups[1] if len(groups) == 2 else None
-                plugins.one(plugin_name, 'webhook', self, right_path)
-        else:
-            self.send_response(404)
+            return send_file(frame_path, mimetype='image/png')
 
 
-class Server(object):
+class Server:
     def __init__(self, config):
         self._enabled = config['video']['enabled']
         self._port = config['video']['port']
         self._address = config['video']['address']
-        self._httpd = None
+        self._origin = None
 
         if 'origin' in config['video']:
-            Handler.AllowedOrigin = config['video']['origin']
+            self._origin = config['video']['origin']
 
         if self._enabled:
             _thread.start_new_thread(self._http_serve, ())
 
     def _http_serve(self):
         if self._address is not None:
-            self._httpd = HTTPServer((self._address, self._port), Handler)
-            logging.info("web ui available at http://%s:%d/" % (self._address, self._port))
-            self._httpd.serve_forever()
+            app = Flask(__name__)
+            app.secret_key = secrets.token_urlsafe(256)
+
+            if self._origin:
+                CORS(app, resources={r"*": {"origins": self._origin}})
+
+            CSRFProtect(app)
+            RequestHandler(app)
+
+            app.run(host=self._address, port=self._port, debug=False)
         else:
             logging.info("could not get ip of usb0, video server not starting")