diff --git a/moodle/README.md b/moodle/README.md
index 9ac32549d5171453df646632b7d7d7cb91689926..77880f5ee4a19bbca9f4b6dcb46297318280ad51 100644
--- a/moodle/README.md
+++ b/moodle/README.md
@@ -6,6 +6,7 @@ useful.
 
 * `assignment-select-users.py` -- select a list of users on the
   assignments page (useful for e.g. granting bulk extensions)
+* `bulk-enrol.py` -- bulk enrol a bunch of students on a module
 * `create-groups.rb` -- ruby script that takes a CSV file of
   students/groups and creates the groups on Moodle.
 * `quiz-get-urls.py` -- make a CSV of all quizzes on a module, for use
diff --git a/moodle/bulk-enrol.py b/moodle/bulk-enrol.py
new file mode 100644
index 0000000000000000000000000000000000000000..23afacb615c5d2f22cfb3bd5b6d6bf1f2e08117b
--- /dev/null
+++ b/moodle/bulk-enrol.py
@@ -0,0 +1,163 @@
+# Script for bulk enrolling students on a module
+#
+# Uses browser_cookie3 to steal your Moodle login cookies from Firefox.
+# Can be adjusted to work for whichever browser you use. Replace
+# "browser_cookie3.firefox" with "browser_cookie3.chrome" or whatever.
+
+import browser_cookie3
+import csv
+import re
+import requests
+import sys
+
+from dataclasses import dataclass
+
+from typing import Dict, Optional
+
+if len(sys.argv) < 3:
+    print("Usage: python bulk-enrol.py <url> <students file>")
+    print("where")
+    print("  <url> is the url of the participants page of the Moodle module")
+    print(
+        "  <students file> a text file listing students "
+        "-- each line is searched on Moodle for a matching student."
+        "If a single student is found, they are enrolled, else you are asked."
+    )
+    exit()
+
+url = sys.argv[1]
+students_file = sys.argv[2]
+
+MOODLE_DOMAIN = "moodle.royalholloway.ac.uk"
+MOODLE_SESSION_COOKIE = "MoodleSession"
+
+SESSKEY_RE = re.compile(r'.*"sesskey":"([^"]*)".*', re.MULTILINE)
+SESSKEY_GRP = 1
+
+ENROLID_RE = re.compile(r'.*name="enrolid"\s*value="([^"]*)"', re.MULTILINE)
+ENROLID_GRP = 1
+
+COURSEID_RE = re.compile(r".*?id=(\d{4})")
+COURSEID_GRP = 1
+
+# e.g userlist = "&userlist[]=160749&userlist[]=161078"
+ENROL_URL = "https://moodle.royalholloway.ac.uk/enrol/manual/ajax.php?" \
+    "mform_showmore_main=0&id={courseid}&action=enrol&enrolid={enrolid}&" \
+    "sesskey={sesskey}&_qf__enrol_manual_enrol_users_form=1&" \
+    "mform_showmore_id_main=0&userlist[]={userid}&roletoassign=5&startdate=3" \
+    "&duration="
+
+GET_USER_URL = "https://moodle.royalholloway.ac.uk/lib/ajax/service.php?" \
+    "sesskey={sesskey}&info=core_enrol_get_potential_users"
+
+@dataclass
+class EnrolInfo:
+    courseid : Optional[str] = None
+    sesskey : Optional[str] = None
+    enrolid : Optional[str] = None
+
+@dataclass
+class Student:
+    userid : str
+    email : str
+
+def get_moodle_cookies() -> Dict[str, Optional[str]]:
+    """Gets current moodle cookies from firefox"""
+    cookie_jar = browser_cookie3.firefox(domain_name=MOODLE_DOMAIN)
+    cookies = {
+        c.name : c.value
+        for c in cookie_jar
+        if c.name == MOODLE_SESSION_COOKIE
+    }
+
+    if len(cookies) == 0:
+        raise Exception(
+            "No",
+            MOODLE_SESSION_COOKIE,
+            "found, please make sure you are logged in."
+        )
+
+    return cookies
+
+def get_enrol_info(
+    cookies : Dict[str, Optional[str]], participants_url : str
+) -> EnrolInfo:
+    info = EnrolInfo()
+
+    if m := COURSEID_RE.match(participants_url):
+        info.courseid = m[COURSEID_GRP]
+
+    response = requests.get(participants_url, cookies=cookies)
+    for line in response.text.split("\n"):
+        if m := SESSKEY_RE.match(line):
+            info.sesskey = m[SESSKEY_GRP]
+        elif m := ENROLID_RE.match(line):
+            info.enrolid = m[ENROLID_GRP]
+
+    return info
+
+def get_user(
+    cookies : Dict[str, Optional[str]], info : EnrolInfo, search : str
+) -> Optional[Student]:
+    url = GET_USER_URL.format(sesskey=info.sesskey, cookies=cookies)
+    r = requests.post(url, cookies=cookies, json=[{
+        "index" : 0,
+        "methodname" : "core_enrol_get_potential_users",
+        "args" : {
+            "courseid" : info.courseid,
+            "enrolid": info.enrolid,
+            "search": search,
+            "searchanywhere" : True,
+            "page" : 0,
+            "perpage" : 101
+        }
+    }])
+
+    students = r.json()[0]["data"]
+
+    if len(students) == 0:
+        return None
+    elif len(students) == 1:
+        return Student(students[0]["id"], students[0]["email"])
+    else:
+        while True:
+            for idx, student in enumerate(students):
+                print(f"{idx} : {student['fullname']} [{student['email']}]")
+            print("q : none")
+            choice = input("Enter choice: ").strip()
+            if choice.lower() == "q":
+                return None
+            try:
+                i = int(choice)
+                if 0 <= i and i < len(students):
+                    return Student(students[i]["id"], students[i]["email"])
+            except ValueError:
+                pass
+
+def enrol_user(
+    cookies : Dict[str, Optional[str]], info : EnrolInfo, userid : str
+):
+    requests.get(ENROL_URL.format(
+        courseid=info.courseid,
+        enrolid=info.enrolid,
+        sesskey=info.sesskey,
+        userid=userid
+    ), cookies=cookies)
+
+cookies = get_moodle_cookies()
+info = get_enrol_info(cookies, url)
+
+with open(students_file) as f:
+    for line in f:
+        search = line.strip()
+        if len(search) == 0:
+            continue
+
+        student = get_user(cookies, info, search)
+        if student is None:
+            print(f"WARNING: no hit for {search}, ignoring.")
+        else:
+            # can probably do them in bulk, but 1 by 1 will avoid limits
+            print(f"Enroling {search} ({student.email} [{student.userid}])")
+            enrol_user(cookies, info, student.userid)
+