If your Moodle site still uses the older H5P plugin (mod_hvp, the “Interactive Content” activity), upgrading to Moodle 4.x or 5.x is the point where that decision catches up with you. mod_hvp is no longer the recommended way to run H5P in Moodle: the native H5P activity (mod_h5pactivity) and the Content Bank have replaced it. This guide covers moving your existing content across with the official migration tool, without losing work or breaking courses.

The two H5P systems, and why they conflict

There are two separate H5P implementations in the Moodle world:

  • mod_hvp: the original third-party plugin by Joubel. Content is stored inside the activity itself. This is the one being phased out.
  • mod_h5pactivity plus the Content Bank: Moodle core’s own H5P support, both introduced in Moodle 3.9. Content lives in the Content Bank as reusable .h5p files; the activity references it.

Both can run side by side, which is what makes the transition manageable, but you do not want to stay in that split state long-term. mod_hvp receives limited maintenance and lags on new Moodle version support, so leaving content there is a slowly accumulating risk.

The migration tool is a separate install, not part of core

The migration is handled by the admin tool Migrate content from mod_hvp to mod_h5pactivity (tool_migratehvp2h5p). This is not bundled with Moodle core. You download and install it from the Moodle plugins directory:

https://moodle.org/plugins/tool_migratehvp2h5p

Install it the same way as any other plugin (drop it in admin/tool/migratehvp2h5p/ or install via the UI, then complete the upgrade). The tool only shows its admin page when mod_hvp is present on the site, so it appears after installation only if you still have the old plugin to migrate from.

What the tool moves, and what it does not

What it does:

  • Copies each piece of mod_hvp content into the Content Bank
  • Creates a new mod_h5pactivity instance pointing at that content, in the same course location
  • Copies completed student results across to the new activity

What it does not move:

  • Unfinished (in-progress) attempts. The native mod_h5pactivity cannot save in-progress state the way mod_hvp can (mod_hvp’s save-state is itself off by default). Any attempt a learner has started but not completed is lost in the migration. Completed results are preserved; in-progress ones are not.
  • mod_hvp’s own settings (display options, hub/usage settings) do not carry over.

If in-progress attempts matter for a particular cohort, migrate after a natural completion point, or warn learners to finish open activities first.

Pre-flight checklist

Run through all of these before touching production:

  1. Take a full backup (database plus moodledata). The migration copies content into the Content Bank, so it is additive, but a backup is non-negotiable before a bulk content operation.
  2. Check disk space. Migrated content is copied into the Content Bank, so moodledata grows. On a media-heavy site that can be a significant increase, so confirm you have headroom before starting.
  3. Enable the Content Bank and confirm the H5P content-type libraries are present (Site administration > H5P > Manage H5P content types). The Content Bank is the destination; if libraries are missing, migrated content will not render.
  4. Decide the disposal policy up front: keep the original mod_hvp activities (the default, recommended) or delete/hide them. Keep them if completed-attempt history needs to remain reachable in its original form.

Running the migration

There are two ways to run it. Use the web UI for small sites and the CLI for large ones.

Web UI (small sites):

  1. Go to Site administration > Migrate content from mod_hvp to mod_h5pactivity (it is a top-level admin menu item once the tool is installed, not under an “H5P” section).
  2. Choose the disposal option for the original activities. Keeping originals is the safe default for a first run.
  3. Start the migration. The web UI processes the migration inline, within the request; it is not queued to cron. On a large site this means the page can run for a long time or time out, which is why the CLI exists.

CLI (large sites, recommended for anything substantial):

sudo -u www-data php admin/tool/migratehvp2h5p/cli/migrate.php --execute

By default the CLI migrates up to 100 mod_hvp activities per run, keeps the originals, and links the new activities to the Content Bank. Run --help to see the options for raising the per-run limit, deleting or hiding the originals, or filtering by content type:

sudo -u www-data php admin/tool/migratehvp2h5p/cli/migrate.php --help

For sites with more than 100 activities, run the script repeatedly; it only migrates each mod_hvp instance once (see below), so re-running picks up the remainder.

After migrating: verify before you trust it

  • Open a sample of migrated activities across different content types (Interactive Video, Course Presentation, and Quiz sets are the ones most likely to surface issues). Confirm they render and play.
  • Check the Content Bank: migrated content should appear there.
  • Confirm completed results carried across, and remember that in-progress attempts did not.
  • Only once you have verified rendering should you consider hiding or removing the original mod_hvp activities.

Re-running and rollback

The tool migrates each mod_hvp activity only once. If you need to re-migrate one (for example, after fixing a rendering problem), remove or rename the migrated h5pactivity first, then run again; the tool will treat the original as not-yet-migrated and process it. Because originals are kept by default, the practical rollback is simply to leave the originals in place until you have fully verified the migrated versions.

Common failures

  • Web UI times out on a large site: expected, because the UI runs inline. Use the CLI script instead.
  • Migrated content does not render: the matching H5P content-type library is missing from the Content Bank. Install or update content types, then re-check.
  • Out of disk space mid-migration: content copying filled the volume. Free space, then re-run; already-migrated activities are skipped.
  • A specific activity reports it could not be migrated: isolate and handle it individually, typically by re-uploading the source .h5p into a fresh activity. The rest of the batch is unaffected.

When to do this

If you are planning a Moodle 4.x or 5.x upgrade and still run mod_hvp, do the H5P migration as a distinct, verified step rather than folding it into the version upgrade. Treating it as its own project, with its own backup and verification pass, keeps two large changes from compounding into one hard-to-debug failure.

Solin specializes in Moodle and Totara upgrades and content migrations. Need help? Contact us.

Moodle 4.3 ships with multi-factor authentication (MFA) as a built-in admin tool. This guide covers enabling TOTP-based 2FA, configuring a grace period so users have time to set up the app, replacing the confusing default grace period message with something users can actually act on, and verifying the setup without risking locking yourself out.

Why an authenticator app, not email

Email-based codes are better than nothing, but they shift the second factor to a channel that may itself be compromised. If an attacker already has access to a user’s email account, an emailed code provides no meaningful protection. A TOTP app (Google Authenticator, Aegis, Microsoft Authenticator, or any compatible app) generates time-based codes that never leave the device. That is what makes it a genuine second factor.

This guide therefore disables the email factor entirely. There is no point offering a weaker alternative alongside a strong one, because users will gravitate toward whichever is easiest.

Before you start

  • Moodle 4.3 or later. MFA is built in as tool_mfa; no plugin installation needed.
  • Site administrator access.
  • A second Moodle administrator account that you can use to recover access if something goes wrong. Set up TOTP on that account before enforcing the policy.
  • Keep a non-incognito browser window with an active admin session open throughout. This is your safety net.

Step 1: Enable MFA

Go to Site administration > Plugins > Admin tools > Multi-factor authentication > Manage multi-factor authentication.

At the top of the page, check the box labeled MFA plugin enabled and save.

Step 2: Enable the Authenticator App factor

On the same Manage multi-factor authentication page, find Authenticator app in the factor list and click its Settings link.

  • Enable factor: Yes
  • Factor weight: 100

Save changes.

Step 3: Disable the Email factor

Back on the Manage multi-factor authentication page, find Email in the factor list and click its Settings link.

  • Enable factor: No

Save changes.

Step 4: Configure the Grace Period

The grace period factor allows users to log in for a set number of days without having completed MFA setup. During this window, Moodle prompts them to get the authenticator app configured. Once the grace period expires, they cannot log in until they have done so.

On the Manage multi-factor authentication page, find Grace period in the factor list and click its Settings link.

  • Enable factor: Yes
  • Grace period: this is a duration field. Enter a number and pick a unit from the dropdown. Set it to, for example, 5 with the unit days (a reasonable default). The shipped default is 1 week.

Save changes.

On the factor list page, make sure Grace period appears after Authenticator app in the order. Use the up/down arrow icons next to each factor to move it down if needed. This ensures Moodle checks whether a user already has the app set up before falling back to the grace period.

Step 5: Fix the grace period warning message

The default message users see during the grace period reads:

You are currently in the grace period, and may not have enough factors set up to log in once the grace period expires. Go to {$a->url} to check your authentication status and set up more authentication factors. Your grace period expires in {$a->time}.

This is unhelpful. Users do not know what “factors” are. The message also does not tell them what to actually do. Replace it with something clear and actionable.

Go to Site administration > Language > Language customisation. Select your language (for example, English (en)) and click Open language pack.

In the filter form, enter the following and click Show strings:

  • String identifier: setupfactors
  • Component: factor_grace

One result appears. In the Local customisation field, paste:

To keep your account secure, we require Two-Factor Authentication (2FA). Go to {$a->url} to set up an Authenticator app on your phone. You have {$a->time} left to do this. After this period, you will not be able to log in without it.

Click Apply changes and continue editing, then Save changes to the language pack.

The placeholders {$a->url} and {$a->time} are filled in by Moodle at runtime, so leave them exactly as written.

Step 6: Test without locking yourself out

Keep your admin session active in the regular browser. Then open an incognito or private browser window and log in as a regular user, either a dedicated test account or a colleague’s account with their permission.

Verify the following:

  • The grace period message appears after login and shows your custom text.
  • The link in the message takes the user to their MFA preferences page.
  • The user can scan the QR code with an authenticator app and complete setup.
  • After setup, logging out and back in prompts for the TOTP code and grants access once the correct code is entered.

If anything is misconfigured, your open admin session lets you correct it without getting locked out.

Once the full flow works, set up TOTP on your own admin account if you have not done so already.

The Moodle question bank filters by tag, and the Tag row carries a Match dropdown with None, Any and All. Match None is the one to reach for when a pool has to exclude something: every question in the bank except those tagged retired, say, or a random question that must never draw from a set that has been withdrawn.

On Moodle 4.3, 4.4 and 4.5, that is not what it returns. Depending on how the bank is tagged, the result is either empty or a list that still contains the very questions the filter was written to keep out. It behaves this way on the question bank screen and in random question selection alike, because both run the same code.

The second case is the one that matters, because nothing on screen indicates that anything went wrong.

Check whether you are affected in 30 seconds

Pick a question that carries two tags, for example retired and chapter1. Filter the question bank with Match set to None and retired selected.

  • If that question appears in the results, you are on an affected version.
  • If it does not appear, and untagged questions do, your filter is working correctly.

If you have not used Match None, you are not affected. Only the None option is involved: Any and All behave the same on 4.3 as they do on 5.2.

What the filter actually returns

On the affected versions, Match None does not mean “questions without the selected tag”. It means “questions that carry at least one tag that is not the selected one”. Those are different sets, and two consequences follow.

Questions you excluded come back anyway. A question tagged both retired and chapter1 satisfies that rule, because chapter1 is not retired. It appears in your “none” result despite being tagged retired.

Untagged questions never appear. A question with no tags at all carries no tag that is not the selected one, so it is always excluded, even though it plainly has none of the tags you picked.

Three columns comparing a six-question bank against what Match None on the tag math returns in Moodle 4.3 to 4.5 versus 5.0 and later

Moodle’s own test fixture shows the difference. Six questions:

Question Tags
q1 math
q2 math, algebra
q3 math, algebra, advanced
q4 geometry
q5 math, geometry
q6 (none)

Filtering with Match None:

Filter 4.3 to 4.5 returns 5.0 and later returns
None: math q2, q3, q4, q5 q4, q6
None: math + geometry q2, q3 q6
None: algebra q1, q2, q3, q4, q5 q1, q4, q5, q6

In the first row, three of the four questions the older versions return are tagged math. That is the exact set the filter was asked to exclude.

This is also why the symptom looks different from site to site. If every question in your bank carries exactly one tag and you filter on it, everything fails the rule and you get an empty list, which at least looks like a problem. If your questions carry several tags, you get a full-looking result that is quietly wrong.

Which versions are fixed

The fix is MDL-84966. It landed in 5.0, 5.1 and 5.2, and was not backported to 4.5 or earlier.

Version Match None General support ends Security support ends
4.5 LTS Broken 2025-10-06 2027-10-04
5.0 Fixed 2026-04-20 (passed) 2026-10-05
5.1 Fixed 2026-10-05 2027-04-19
5.2 Fixed 2027-04-19 2027-10-04

If you are already on 5.0 or later, Match None does what the label says and there is nothing to do.

If you are upgrading in order to resolve this, go to the latest Moodle release your site can take, rather than to the earliest version that carries the fix. Check its PHP requirement and its supported upgrade path first: Moodle does not support an unlimited jump, so a site on an older 4.x may need an intermediate step.

Note that 4.5 is the current LTS, with security support running to October 2027. Many sites will legitimately stay on it for another year or more. Unless they apply a tested local backport of the fix, those sites keep this behavior for as long as they stay on 4.5, which is what makes the workaround below worth setting up.

The workaround: tag what you want, not what you don’t

If you cannot upgrade, stop expressing the rule as an exclusion. Add a second tag to the questions you do want, for example active, and filter with Match Any on active.

Be clear about what this changes. You are swapping the rule “everything except retired” for the rule “everything tagged active“. Those are not the same rule, and they only stay equivalent if you maintain the active tag deliberately. It is the safest workaround that needs no code, for three reasons.

It works before and after the fix. Any and All are not affected by this bug, so the same configuration behaves the same on 4.3 as on 5.2 and survives the upgrade without being unpicked.

It fails visibly rather than silently. This is the real argument. Both rules can go wrong, but they go wrong in different ways. If somebody forgets to tag a new question active, it is missing from a pool that is supposed to contain it, and that tends to get noticed. The broken exclusion rule quietly hands out questions you meant to withhold, and nothing reports it.

It behaves the same in quizzes. Random question criteria run through the same filtering code as the bank screen, so a positive tag is predictable in both places.

The maintenance cost is real, so plan for it. A new question that belongs in the pool has to be tagged, and a question that carries both retired and active lands back in the pool you were trying to protect. Both are worth a periodic check.

On the other approaches: moving questions into a separate category is a perfectly reasonable design when the pools are genuinely disjoint, and only becomes awkward when a question needs to belong to more than one. Filtering with Match Any across every other tag works until somebody adds a tag, at which point the pool changes without anyone touching the quiz. Removing the retired tag destroys information you presumably wanted to keep.

If you are staying on 4.5 and lean on exclusion filters heavily, a tested local backport of the MDL-84966 patch is the other option. It restores the intended behavior rather than working around it, at the cost of carrying a core patch across every future update.

This is not confined to the question bank screen

The same filtering code drives random question selection in quizzes. When a learner starts an attempt, Moodle reads the slot’s stored filter and asks the question loader for a question, and that loader builds its query with exactly the same code the bank screen uses. There is no separate implementation for quizzes.

So on 4.3 through 4.5, a random question configured with a Match None tag criterion has been drawing from an incorrectly defined pool. What that means in practice depends on your tag data: the pool may include questions the criterion was written to keep out, it may omit questions that should have been eligible, and where a single tag is used throughout the bank it may be empty. The pool definition is wrong on every attempt, which is not the same as saying every attempt served an excluded question.

If you already built a pool this way

Any quiz on an affected version using a random question with a Match None tag criterion is worth checking. If the excluded questions were excluded for a reason, for instance because they are reserved for a final assessment, they may already have been served to learners. Nothing in the quiz or the bank reports this. The filter returns a plausible looking set and the attempt proceeds normally.

A practical order to work through it:

  1. Find the affected slots. Random question criteria are stored as JSON in question_set_references.filtercondition. The tag filter is keyed qtagids, and Match None is jointype 0. This query finds candidates:

    SELECT id, itemid, filtercondition
      FROM mdl_question_set_references
     WHERE filtercondition LIKE '%"qtagids"%'
       AND filtercondition LIKE '%"jointype":0%';

    Read the matches before acting on them. The LIKE will also match a jointype 0 belonging to a different condition in the same JSON, so confirm that the 0 sits inside the qtagids object.

  2. Work out the gap. For each affected slot, compare what the criterion was meant to select against what the buggy rule actually returns for your tag data.

  3. Then look at attempts, to see whether any of the wrongly included questions were actually served.

If no current random-question slot uses Match None, there is no live quiz configuration to remediate. That is not quite the same as saying nothing ever went out: a slot that was configured this way and has since been edited or deleted leaves no trace in the current configuration, so if the stakes are high enough, the attempt data is the only place that would still show it.

Under the hood

This section explains the cause in the source. You do not need any of it to apply the fix above, so skip it if you just want the pool working.

The Tag filter builds its SQL in question/bank/tagquestion/classes/tag_condition.php. On 4.3 through 4.5, choosing None flips the comparison inside the subquery:

$equal = !($jointype === datafilter::JOINTYPE_NONE);
[$tagsql, $tagparams] = $DB->get_in_or_equal($selectedtagids, SQL_PARAMS_NAMED, 'param', $equal);

which produces:

q.id IN (SELECT ti.itemid
           FROM {tag_instance} ti
          WHERE ti.itemtype = 'question'
            AND ti.component = 'core_question'
            AND ti.tagid NOT IN (:selected)
       GROUP BY ti.itemid)

Read that carefully. It selects questions that have a tag row whose tag is not the selected one. A question tagged retired and chapter1 has such a row, so it matches. A question with no tags has no rows at all, so it cannot match. Both surprises fall straight out of the query.

From 5.0 onward the negation moved to the outer clause, which is what the label means:

q.id NOT IN (SELECT ti.itemid
               FROM {tag_instance} ti
              WHERE ti.itemtype = 'question'
                AND ti.component = 'core_question'
                AND ti.tagid IN (:selected

The negation is the whole difference: negating set membership is not the same as negating the comparison used to build the set.

Worth noting for anyone maintaining plugins in this area, because it explains how a bug like this can go unnoticed rather than why this particular one did: there were no unit tests for this condition until the fix added tag_condition_test.php. A wrong-results bug with no assertion covering it produces no failing build, and because the filter still returns something, it produces no visible error either. Those two together are what let a defect sit in a shipped feature.

You open a course page and it is visibly broken. Everything looks fine at the top, then partway down the layout collapses: activities shift sideways, blocks jump to the wrong column, sections sit inside one another, or the whole right-hand side disappears. The activities at the top are untouched; everything below a certain point is mangled. Clearing caches and switching themes makes no difference. This guide explains what causes that specific failure and how to fix it.

What you are looking at

The tell-tale sign is that the breakage starts at a point on the page and affects everything after it, while everything before it is fine. That points to a single unclosed or malformed HTML tag in one piece of content, an unclosed <div>, <table>, or <p>, usually pasted in from Microsoft Word. Moodle renders a course page as one continuous HTML document, so an unbalanced tag in one activity does not stay contained: the browser keeps the tag “open” and every subsequent activity gets pulled inside it, which is why the damage cascades downward from one point.

The content that introduced it often looks completely normal in the editor. The visible damage appears further down the page, in activities that have nothing to do with the one that actually contains the broken tag. That mismatch is what makes this so confusing to diagnose by eye.

The thing most people miss first

Before hunting for the bad tag, check one setting, because on a standard Moodle site this symptom should not be possible at all.

By default, Moodle runs all HTML content through a cleaner (HTMLPurifier) before displaying it. That cleaner automatically closes unclosed tags and fixes broken nesting, so a malformed Word paste is repaired before it ever reaches the page. On a default site, the cascade described above simply does not happen.

It becomes possible only when “Enable trusted text” is switched on. That setting tells Moodle to skip the cleaning for content authored by trusted roles (anyone with the moodle/site:trustcontent capability), so that those users can use advanced markup the cleaner would otherwise strip. The side effect: their malformed markup is no longer auto-repaired, and a single unclosed tag now breaks the page exactly as you are seeing.

So the first move is to check it:

Site administration > Security > Site security settings > Enable trusted text.

If it is on, you have found the condition that allows the breakage. You now have two routes: turn the setting off (the broad fix), or find and fix the offending tag (the surgical fix). Often you want both, the setting change to prevent recurrence, the tag fix to repair the page that is already broken.

The broad fix: turn trusted text off

If your site does not actually need trusted text (most do not, it exists to allow scripts and embeds that the cleaner removes), turning it off restores Moodle’s automatic tag-balancing for all content. Existing malformed content then gets cleaned on render, and the layout repairs itself without you touching any individual activity.

Weigh this before flipping it: if trusted authors rely on embedded markup (custom scripts, certain iframes), turning trusted text off will strip that markup. If you are not knowingly using that capability, it is safe and is the cleanest prevention.

The surgical fix: find and repair the bad tag

If you need to keep trusted text on, or you want to repair the specific page, locate the unbalanced tag:

  1. View the page source (Ctrl+U in Chrome or Firefox). Scan for an opened <div> or <table> with no matching close near where the layout starts to break.
  2. Use developer tools (F12), Elements panel. The browser auto-corrects malformed markup as it builds the DOM; the point where its correction diverges from what you intended usually sits right at the broken tag.
  3. The W3C validator (validator.w3.org) can check markup, but its “validate by URL” mode cannot reach a login-protected course page. Use “validate by direct input” and paste the page source instead.

The activity to fix is the one whose content holds the unclosed structural tag, frequently a Label, Page, or Text and media area where formatted content was pasted. Edit it, switch the editor to its HTML source view (in TinyMCE, the HTML source-code button), close or remove the unbalanced tag, and save. If the editor keeps re-mangling the markup, set your editor preference to Plain text area temporarily, correct the raw HTML, and save. Reload the course page: the layout below the fixed content should be restored.

Preventing it from happening again

  • The most reliable prevention is to stop malformed Word markup entering content at all. Tell authors to paste as plain text (Ctrl+Shift+V pastes without source formatting in most browsers) and apply formatting in the editor afterwards.
  • If you do not have a genuine need for trusted content, leave “Enable trusted text” off. That single setting is the difference between Moodle quietly repairing a bad paste and a bad paste taking down half a course page.

The short version

A course page that breaks from one point downward is almost always a single unclosed HTML tag in pasted content, and on a healthy Moodle site it should not happen, because Moodle cleans HTML by default. If it is happening, “Enable trusted text” is switched on and is bypassing that cleaning. Check that setting first: turn it off to fix the whole class of problem, and fix the offending activity’s HTML to repair the page that is already broken.

You deploy a code update or upgrade to a Moodle site, and afterwards the Edit mode toggle does nothing. You click it, the page reloads, and you are still not in editing mode (or the editing controls never appear). The site otherwise loads fine. This is almost always a stale JavaScript or theme cache, not a code problem, and it clears in a couple of minutes once you know what to purge.

Why a deployment causes it

Moodle serves its interface JavaScript and theme CSS from caches keyed on a revision number. When you deploy new code, the files on disk change, but browsers and the server may still be holding the previous cached versions. The Edit mode toggle is driven by a JavaScript module (an AMD module that calls a web service to flip your editing state). If the browser is running stale or half-updated JavaScript, the toggle silently fails to do its job even though the rest of the page renders normally.

This is why it specifically shows up after a deploy: the HTML and PHP are new, but the JS/theme layer is still cached from before. The fix is to force those caches to regenerate.

The fix, in order

1. Purge all caches. This is the single most likely fix. From the command line:

sudo -u www-data php admin/cli/purge_caches.php

Or, in the UI: Site administration > Development > Purge caches. This rebuilds the JavaScript and theme caches with a fresh revision, so browsers are served the new code.

2. Hard-refresh the browser. After purging server-side, your browser may still hold the old JavaScript. Do a hard reload (Ctrl+Shift+R, or Cmd+Shift+R on macOS) on the page where Edit mode is broken. A normal refresh is not always enough because the cached JS can be served from the browser’s own cache.

3. Reset the opcache if your server uses it. On a production server with opcache.validate_timestamps=0 (common on tuned hosts), PHP keeps serving the old compiled code until opcache is reset, which a cache purge does not do. Reload PHP-FPM (or the web server) to clear it. If you are not sure whether this applies, restarting the PHP service after a deploy is a safe habit.

After these three, click Edit mode again. In the overwhelming majority of cases it now works.

Confirming it is a JavaScript/cache issue

If you want to verify the cause rather than just apply the fix, open your browser’s developer tools (F12), go to the Console and Network tabs, and click the Edit mode toggle:

  • A failed request to Moodle’s AJAX service endpoint (/lib/ajax/service.php), or a JavaScript error in the console, confirms the JS layer is the problem.
  • A clean network log with no toggle request firing at all points to the JavaScript module not loading, again a cache/build issue.

Either way, the remedy is the cache purge and hard refresh above.

If it still does not work

A few less common causes, worth checking only after the cache steps:

  • Development cache settings left on. If $CFG->cachejs = false or $CFG->themedesignermode = true is set in config.php, JavaScript and theme assets are rebuilt on every request, which is slow and can serve inconsistent assets under load. These are explicitly “not for production servers”; remove them on a live site.
  • A theme that failed to compile. If the deploy included a theme change and the theme has a SCSS error, the theme cache can fail to build. Check the site with a known-good theme (for example, Boost) to isolate it.
  • A genuinely incomplete deployment. If the deploy itself was partial (interrupted file sync, failed git checkout), files may be missing or mismatched. Confirm the working tree is clean and matches the intended release before chasing caches further.

Avoiding it on future deploys

Make a cache purge (and an opcache reset, if applicable) a standard final step of your deployment process, after the code is in place and any database upgrade has run. Treating it as part of the deploy rather than a reaction to a broken toggle means the editing UI is never serving stale JavaScript to begin with.

Search for gamification in Moodle and you will find the same promise everywhere: add points, badges, and a leaderboard, and watch bored learners turn into motivated ones. It is an appealing pitch, and it is mostly sold without evidence. The honest picture is more mixed. Gamification can genuinely help, but the exact mechanics that get sold hardest are also the ones most often linked to things going wrong. This guide lays out what the research actually supports, where it backfires, and how to approach gamification in Moodle so you get the upside without the traps.

What the evidence actually says

The research base is real, but it is not the one-sided success story vendors imply. A 2023 systematic mapping study by Almeida and colleagues found that badges, leaderboards, competitions, and points are the game design elements most often reported as causing negative effects: learners gaming the system, a novelty spike that fades, and demotivation for everyone who is not near the top. The authors concluded plainly that gamified software is “prone to generate harmful effects” (Almeida et al., 2023).

The upside is real too, but uneven, and the studies do not fully agree, which is itself telling. A 2023 meta-analysis of gamification in education (Li, Ma and Shi, 2023) found a large overall effect on learning outcomes, with motivation showing the single biggest gain. A separate 2024 meta-analysis (Li, Hew and Du, 2024) found that gamification reliably improved students’ intrinsic motivation and their sense of autonomy and relatedness, but had only a minimal effect on their competence. Put the two together and the honest reading is this: gamification is consistent at moving motivation, engagement, and belonging, and far less consistent at improving actual mastery. It can get people to show up and keep going. It does not, on its own, make them better at the subject. Both sides of that are true at once, and holding them together is the whole game.

The mechanics that backfire

Three patterns cause most of the damage, and they are exactly the default gamification toolkit.

  • Public competitive leaderboards. A whole-cohort ranking motivates the handful of people at the top and quietly demoralizes everyone else. The learners who most need encouragement see themselves stuck at the bottom and disengage. If you rank people publicly, you are designing for the few, not the many.
  • Points for everything. When you attach points to work people were already doing for their own reasons, you can crowd out that internal motivation. This is the overjustification effect, documented across decades of motivation research: once the reward is the point, take it away and the behavior can drop below where it started (Deci, Koestner and Ryan, 2001). Points also invite gaming, clicking through content to farm the score without learning anything.
  • Participation-trophy badges. Badges handed out for merely showing up carry no signal and quickly become noise. Learners stop noticing them, and the badges that should mean something, a real competency demonstrated, get lost in the pile.

None of this means the mechanics are useless. It means they are sharp tools that cut both ways, and bolting them on without a plan is how you get activity that looks like engagement but does not last.

Start from a behavior, not a feature

The mistake is to start with “let’s add gamification”. Start instead with a specific behavior you want more of, and be honest about whether a game mechanic actually serves it. “I want people to come back and practice regularly” is a behavior a streak or a habit nudge can genuinely support. “I want higher exam scores” is not something a leaderboard will deliver, and pretending otherwise sets you up to be disappointed. Match the mechanic to what the evidence says it can do, consistency and belonging, and stop there.

How to do it right in Moodle

Moodle gives you most of what you need without any add-on, and the native tools tend to be the well-designed ones. A practical, low-risk approach:

  • Use Open Badges for things that are actually earned. Moodle’s built-in badges are criteria-based: tie them to completing a real activity, passing an assessment, or demonstrating a competency, not to logging in. A badge that means something is worth far more than ten that do not.
  • Lean on activity completion and competencies. A clear completion trail and a visible progress bar are quietly motivating in a way that does not backfire. They show progress without ranking anyone against anyone else.
  • If you use a points plugin, configure against pure competition. The most widely used gamification plugin for Moodle, Level Up (block_xp), does points, levels, and leaderboards well, but the defaults lean competitive. Where the tool allows it, prefer private or team-based views over a public whole-site ranking, and treat levels as a personal progress signal rather than a race.
  • Make competitive elements opt-out and private by default. Let learners keep their standing to themselves. Someone who does not want to be on a leaderboard should never be forced onto one.
  • Do not gamify high-stakes assessment. Keep points and rewards away from the graded work that really matters, precisely because of the overjustification effect. Gamify the practice, the revisiting, the daily rhythm, and let the assessment stand on its own.
  • Scope it narrowly. Gamification fits courses with a genuine ongoing rhythm: language practice, compliance refreshers, skills that need regular reps. It does not belong on every course by default. Applied everywhere, it becomes wallpaper.

Measure the right thing

If you do add gamification, judge it by sustained behavior, not by vanity metrics. Points awarded and badges issued go up by definition the moment you switch the feature on; they tell you nothing. Watch whether people come back over weeks, whether completion of the target activity holds, and whether the effect survives after the novelty wears off. If the only thing that grew is the score, the gamification is working on itself, not on your learners.

The mechanic most people overlook: the streak

One mechanic lines up unusually well with what the evidence supports, and it is the one the big Moodle gamification plugins never shipped: the daily streak. Come back, keep your run alive, do not break the chain. It targets consistency and habit directly, which is exactly where gamification is strongest, rather than dangling a ranking or a reward. Done carelessly a streak can still turn into anxiety or a number people chase for its own sake, so the design matters: let learners opt out and stay private, forgive the occasional missed day with a streak freeze so one slip does not erase weeks of effort, and only apply it where a regular rhythm genuinely makes sense.

We built exactly that as a free, open-source Moodle plugin. Solin Streaks adds a per-learner streak counter, streak freezes, at-risk reminders, and a per-course leaderboard, and it renders natively in the Moodle app. It is designed around the caveats in this guide: opt-out and private by default, forgiving rather than punishing, and meant for the courses where a habit actually fits. You can install it from the Moodle Marketplace, and read the full documentation on the Solin Streaks GitHub repository.

Solin designs and builds gamification for Moodle and Totara that is grounded in what the evidence actually supports, set up and themed for you. Contact us if you want gamification done properly.

References

  1. Almeida, C., Kalinowski, M., Uchôa, A., & Feijó, B. (2023). Negative Effects of Gamification in Education Software: Systematic Mapping and Practitioner Perceptions. Information and Software Technology, 156. arxiv.org/abs/2305.08346 (open-access preprint).
  2. Li, M., Ma, S., & Shi, Y. (2023). Examining the effectiveness of gamification as a tool promoting teaching and learning in educational settings: a meta-analysis. Frontiers in Psychology. pmc.ncbi.nlm.nih.gov/articles/PMC10591086 (open access).
  3. Li, L., Hew, K. F., & Du, J. (2024). Gamification enhances student intrinsic motivation, perceptions of autonomy and relatedness, but minimal impact on competency: a meta-analysis and systematic review. Educational Technology Research and Development, 72(2), 765–796. doi.org/10.1007/s11423-023-10337-7.
  4. Deci, E. L., Koestner, R., & Ryan, R. M. (2001). Extrinsic Rewards and Intrinsic Motivation in Education: Reconsidered Once Again. Review of Educational Research, 71(1), 1–27. selfdeterminationtheory.org (PDF) (open access).

When you switch a Moodle account from manual authentication to SAML2 single sign-on, any user who still has the “Force password change” flag set gets locked out. They cannot log in and cannot self-resolve it. This guide explains why it happens and how to clear it safely, in bulk.

The symptom

A user authenticates successfully through your identity provider, then instead of landing in Moodle they hit an error page:

You cannot proceed without changing your password, however there is no available page for changing it. Please contact your Moodle Administrator.

It is not a redirect loop, and it is not a failed login. SSO works; Moodle then refuses to let the user proceed. This typically appears right after you migrate a set of accounts from manual auth to saml2, or after a bulk user import that set the force-password-change flag.

Why it happens

Moodle stores a per-user preference, auth_forcepasswordchange, set to 1 when an account must change its password at next login. On every login, core checks this flag (lib/moodlelib.php). If it is set, Moodle wants to send the user to the change-password form, but only if the account’s auth method can actually change a password locally:

  • For manual accounts, the password is local, so Moodle shows the change-password form. Normal behavior.
  • For SAML2 (and other external SSO) accounts, the password is owned by the identity provider, not Moodle. The auth plugin reports that it cannot change passwords, so there is no form to send the user to. Moodle has nowhere to route them, so it stops with the “no available page for changing it” error.

The flag is harmless while the account is manual. It becomes a lockout the moment the account is switched to SSO without the flag being cleared first. So the accounts most affected are exactly those created or imported as manual with “force password change” ticked, then later moved to SAML2.

Finding affected users

The flag lives in mdl_user_preferences. To list SSO users who are currently carrying it:

SELECT up.userid, u.username, u.email
FROM mdl_user_preferences up
JOIN mdl_user u ON u.id = up.userid
WHERE up.name = 'auth_forcepasswordchange'
  AND up.value = '1'
  AND u.auth = 'saml2';

This is a read-only query and safe to run anywhere. It tells you the scope before you change anything.

Clearing the flag

The correct way to clear it is to delete the preference, which is exactly what Moodle does internally when a password change completes (unset_user_preference('auth_forcepasswordchange', $user) in login/lib.php). Setting the value to 0 also works at read time but leaves a stale row behind; deleting it is the clean equivalent of the core behavior.

Preferred: the Moodle way, per user. For one or a few accounts, edit the user’s profile (Site administration > Users > Browse list of users > select the user > Edit profile) and untick “Force password change”. This goes through Moodle’s API and purges caches correctly.

For a bulk fix, the safest route that still goes through the Moodle API is a short script run via admin/cli, iterating the affected user ids from the query above and calling unset_user_preference('auth_forcepasswordchange', $userid) for each. That clears the preference and invalidates the per-user preference cache the way the UI does.

If you clear it with direct SQL, understand the trade-off. A statement like:

DELETE FROM mdl_user_preferences
WHERE name = 'auth_forcepasswordchange'
  AND userid IN (SELECT id FROM mdl_user WHERE auth = 'saml2');

will remove the flag, but it bypasses Moodle’s caching: user preferences are cached, so you must purge caches afterwards (admin/cli/purge_caches.php) or affected users may still hit the stale flag until the cache expires. Direct writes to a production database should always be preceded by a backup and ideally run inside a transaction. The unset_user_preference route avoids all of this, which is why it is preferred over hand-written SQL.

Preventing it on the next migration

When you migrate a batch of accounts to SAML2, clear auth_forcepasswordchange as part of the same migration step, before or alongside the auth switch, so no account ever ends up SSO-authenticated with the flag still set. If you provision accounts from an upstream system (CRM, HR feed) that sets “force password change” by default, turn that off for accounts destined for SSO.

What not to reach for

admin/cli/reset_password.php does not help here: it only operates on manual-auth accounts and only sets a password, so it neither targets your SAML2 users nor clears the force-change flag. The fix is clearing the preference, not resetting a password that the identity provider owns.

Users see a passing score in the gradebook but receive a "Failed" status that blocks completion or certificate generation. The cause is a mismatch between the decimal precision Moodle uses for display and the raw value it uses for the pass/fail comparison. This guide explains why it happens and how to fix it, with the most robust fix first and database diagnostics after.

A note on versions: the explanation and the gradebook-based fixes here apply to current and older Moodle alike. The SCORM database tables were restructured in Moodle 4.1, so this guide gives the modern table layout first and the pre-4.1 layout as a clearly-marked alternative. Plenty of production sites still run older Moodle versions, so both are worth documenting.

Why the scores diverge

Moodle stores grades with five decimal places of precision. A SCORM object that reports a score of 79.55 has exactly that value stored. The gradebook decimal setting, typically one or two places, only changes how the value is displayed. With one decimal place, 79.55 shows as 79.6; with no decimal places it shows as 80.

The problem arises when all three of these are true:

  • The gradebook is configured to show 0 decimal places (the score appears as 80)
  • The pass threshold is set to 80 (so 80.0 or higher is required)
  • The actual stored value is 79.55

The pass/fail check uses the stored value, not the displayed one. 79.55 >= 80.0 is false, so the user fails, while the gradebook shows 80. The user and the administrator both see what looks like a passing score. This is confirmed in Moodle core: the pass check compares the stored five-decimal finalgrade against gradepass directly, and the decimal setting is documented as affecting display only, not calculations.

This affects SCORM activities, quiz grade boundaries, and any completion condition based on a grade threshold.

Fixing it

Three approaches, in order of preference. The first two are done entirely in the Moodle interface and work on every version.

1. Align the pass threshold with the display precision. If the gradebook shows no decimal places and users expect 80% to pass, set the pass grade to 79.5 instead of 80. Any value that rounds up to 80 in the display will then also pass the comparison. In the activity (SCORM or quiz) settings:

Activity settings > Grade > Grade to pass: 79.5

This is the cleanest fix because it is per-activity, reversible, and needs no database access. Note that it does slightly lower the real pass criterion, so use it where the intent is "a displayed 80 should pass".

2. Increase gradebook decimal precision. If you show two decimal places, users and administrators see the real score (79.55) and understand why it fails. Set this under:

Site administration > Grades > Grade item settings > Overall decimal places

This is a site-wide default (it can be overridden per grade item), so evaluate the impact on all gradebooks before changing it.

3. Fix the SCORM content. If the SCORM package is under your control, adjust the calculation to return integer or properly bounded scores. A SCORM that reports cmi.core.score.raw = 80 will always pass an 80% threshold cleanly.

Confirming it from the database

If you want to verify the discrepancy directly, the gradebook tables tell the whole story and are the same across versions. Check what the gradebook holds for the activity (replace the placeholder with the activity instance id):

SELECT gg.userid, gg.rawgrade, gg.finalgrade, gi.gradepass
FROM mdl_grade_grades gg
JOIN mdl_grade_items gi ON gi.id = gg.itemid
WHERE gi.itemtype = 'mod'
  AND gi.itemmodule = 'scorm'
  AND gi.iteminstance = ?
ORDER BY gg.userid;

A finalgrade of 79.55 paired with a gradepass of 80.0 confirms the issue: the gradebook displays 80 (rounded), but 79.55 >= 80.0 fails.

For a quiz, the pass grade is not stored on the quiz record. Like all activities, it lives on the activity’s grade item, so query it the same way:

SELECT gi.iteminstance AS quizid, gi.grademax, gi.gradepass
FROM mdl_grade_items gi
WHERE gi.itemmodule = 'quiz'
  AND gi.iteminstance = ?;

To set a quiz pass grade, use the activity’s Grade > Grade to pass field rather than editing the database; Moodle writes it to the grade item for you.

Checking the raw SCORM score

To see the exact score the SCORM package reported, the query depends on your Moodle version, because the SCORM tracking tables were restructured in Moodle 4.1.

Moodle 4.1 and later (tracking split across scorm_attempt, scorm_element, and scorm_scoes_value):

SELECT a.userid, e.element, v.value
FROM mdl_scorm_scoes_value v
JOIN mdl_scorm_element e ON e.id = v.elementid
JOIN mdl_scorm_attempt a ON a.id = v.attemptid
WHERE a.scormid = ?
  AND e.element IN ('cmi.core.score.raw', 'cmi.score.raw', 'cmi.score.scaled')
ORDER BY a.userid;

Moodle 4.0 and earlier (tracking held in a single scorm_scoes_track table):

SELECT userid, element, value
FROM mdl_scorm_scoes_track
WHERE scormid = ?
  AND element IN ('cmi.core.score.raw', 'cmi.score.raw', 'cmi.score.scaled')
ORDER BY userid;

The CMI element names are the same in both: cmi.core.score.raw for SCORM 1.2, cmi.score.raw and cmi.score.scaled for SCORM 2004.

Identifying affected users in bulk

To find users whose stored grade fails but whose rounded (displayed) grade would pass, query the gradebook directly. This works the same on all versions because it uses the grade tables, not the SCORM tables:

SELECT gg.userid, gg.finalgrade, gg.rawgrade, gi.gradepass
FROM mdl_grade_grades gg
JOIN mdl_grade_items gi ON gi.id = gg.itemid
WHERE gi.iteminstance = ?
  AND gi.itemmodule = 'scorm'
  AND gg.finalgrade < gi.gradepass
  AND ROUND(gg.finalgrade, 0) >= gi.gradepass;

This returns users whose stored grade fails but whose rounded grade would pass: each is potentially affected by the mismatch. The ROUND(..., 0) here matches a gradebook set to 0 decimal places; if your gradebook shows one decimal place, use ROUND(gg.finalgrade, 1) instead so the query mirrors what users actually see.

Sometimes you need Moodle to do something extra when a particular thing happens: add every new user to a cohort, push a course completion to an external system, or send a tailored notification when someone enrolls. The wrong way to do this is to edit a core file. That change is invisible to the next person, it breaks on upgrade, and it puts you outside supported Moodle.

The right way is an event observer. Moodle fires a typed event for almost everything that happens in the system, and any plugin can register a small handler that runs when a given event is dispatched. Your code lives entirely in your own plugin, so it survives upgrades and is easy to reason about.

This guide walks through building a minimal observer plugin from scratch, using a real example: when a user is created, read a profile field and add them to the cohort that matches it. Routing users into the right cohort by a characteristic such as region or department is genuinely useful automation, since that cohort can then drive enrollments through cohort sync, target audiences, or reports. Core has no built-in setting for it, and the event hands you the user you need to act on.

What an event is

When something significant happens, core triggers an event object, for example \core\event\user_created or \core\event\course_completed. The event carries structured data: who did it, which object it concerns, and a small bag of extra fields. You can browse every event your site fires under Site administration, Reports, Events list. That page is the canonical list of event class names for your exact version, which matters because the names have shifted over the years.

An observer is a static method you register against an event name. Moodle calls it and passes the event object. That is the whole model.

The plugin skeleton

An observer needs to live in a plugin. A local plugin is the natural home for site-specific glue like this. The minimum is three files under local/autocohort/.

First, version.php, which tells Moodle the plugin exists and what it needs:

<?php
defined('MOODLE_INTERNAL') || die();

$plugin->component = 'local_autocohort';
$plugin->version   = 2026110300;
$plugin->requires  = 2024100700; // Moodle 4.5.
$plugin->maturity  = MATURITY_STABLE;
$plugin->release   = '1.0';

Second, a language file at lang/en/local_autocohort.php so the plugin has a name in the admin interface:

<?php
defined('MOODLE_INTERNAL') || die();

$string['pluginname'] = 'Automatic cohort assignment';

Third, the part that does the work: the event registration and the handler.

Registering the observer

Observers are declared in db/events.php. It returns an array of observer definitions. Each one ties an event name to a callback:

<?php
defined('MOODLE_INTERNAL') || die();

$observers = array(
    array(
        'eventname' => '\core\event\user_created',
        'callback'  => 'local_autocohortobserver::user_created',
    ),
);

Two optional keys are worth knowing about. priority controls the order when several observers watch the same event, higher first. internal controls when your handler runs, and it is worth understanding, so it gets its own section below. The default for both is usually what you want, so leave them out unless you have a reason.

The handler

The callback is a static method on a class Moodle can autoload. Place it at classes/observer.php, and the namespace follows from the plugin name. This handler reads a custom profile field, here one with the shortname region, and adds the user to the cohort whose idnumber matches that value:

<?php
namespace local_autocohort;

defined('MOODLE_INTERNAL') || die();

class observer {

    /** Shortname of the custom profile field that drives cohort membership. */
    const PROFILE_FIELD = 'region';

    /**
     * Add a newly created user to the cohort matching their profile field.
     *
     * @param \core\event\user_created $event
     */
    public static function user_created(\core\event\user_created $event) {
        global $CFG, $DB;
        require_once($CFG->dirroot . '/user/profile/lib.php');
        require_once($CFG->dirroot . '/cohort/lib.php');

        // objectid is the id of the user that was just created. Custom profile
        // data is already saved at this point in the standard creation paths.
        $profile = profile_user_record($event->objectid, false);

        $region = '';
        if (isset($profile->{self::PROFILE_FIELD} {
            $region = trim($profile->{self::PROFILE_FIELD});
        }
        if ($region === '') {
            // No value set, nothing to do.
            return;
        }

        // Convention: the cohort's idnumber matches the profile field value.
        $cohort = $DB->get_record('cohort', array('idnumber' => $region;
        if (!$cohort) {
            return;
        }

        cohort_add_member($cohort->id, $event->objectid);
    }
}

A few things to note. The event object is read only, so you take data out of it but never change it. The user id comes straight from $event->objectid, with no guessing and nothing to match against, because the event is about that exact user. Driving the cohort off the profile field value, by matching it against the cohort idnumber, means you never touch the code again to onboard a new region: an administrator just creates a cohort whose idnumber matches the field value. And cohort_add_member already ignores duplicates, so there is no need to check membership first.

One timing point is worth knowing. This works because custom profile data is saved before the user_created event fires in the standard creation paths. The add-user form, self-registration, and bulk upload all save the profile data first and then trigger the event. If you also want to react when an existing user’s field changes later, observe \core\event\user_updated as well, with its own handler.

To try it: add a custom profile field with the shortname region under Site administration, Users, User profile fields. Create a cohort whose idnumber is one of the field values, say emea. Then create a user with region set to emea. They land in the cohort automatically.

When your handler runs: the internal flag

The internal flag decides the moment your observer fires, and it is worth understanding before you build anything heavier than this.

When internal is true, which is the default if you omit it, Moodle calls your observer immediately, in the same database transaction as the action that triggered the event. That is exactly right for fast local work like adding a cohort member. It is quick, it is part of the same atomic operation, and if the surrounding action rolls back, your change rolls back with it.

When internal is false, Moodle buffers the event and dispatches it after the surrounding transaction commits. You want this for anything that should only happen once the triggering action is definitely saved, for example talking to an external system.

One rule follows from this: never do slow or external work directly inside an observer, whatever the flag. An HTTP call to another system inside a synchronous observer blocks the user’s request and can wedge a transaction. For that kind of work, have the observer do the cheap part, capture the ids it needs, and hand the slow part off to an ad hoc task that runs on cron. The observer stays fast, and the heavy lifting happens out of band.

Activating the plugin

Moodle caches the observer list, so dropping the files in place is not enough. The plugin has to be installed and the caches rebuilt:

  1. Copy the plugin into place at local/autocohort/.
  2. Run the upgrade so Moodle registers the new plugin and its observers. On the command line that is admin/cli/upgrade.php, or just visit the notifications page as an administrator.
  3. Purge caches with admin/cli/purge_caches.php, or through Site administration, Development, Purge caches.

Whenever you change db/events.php afterward, bump the version number in version.php and run the upgrade again. The observer cache only rebuilds when the plugin version changes, so a forgotten version bump is the usual reason a new observer never seems to fire.

Where this pattern fits

The same three-file shape covers a huge range of needs. Swap the event name and the handler body, and you can react to enrollments, completions, logins, grade changes, or anything else in the events list. Reach for it when you want behavior that fires off the back of a normal Moodle action, when the event carries the data you need, and when you would rather not touch core. Because everything lives in your own plugin, you get behavior core does not offer without giving up clean upgrades. That trade is almost always the right one.

Solin builds and maintains custom Moodle and Totara plugins, including event-driven integrations with external systems. If you would rather have this handled end to end, we are happy to help.

Moodle ships with a security checklist built right into it that most admins have never opened. It is under Site administration > Reports > Security checks. Every time you load that page, Moodle runs a series of tests against your live configuration and tells you, in plain language, what is safe and what is not. Most sites have at least two or three settings wrong. This guide walks through the five that come up most often, what each one exposes, and exactly where to fix it. Paths and labels are for Moodle 4.5.

1. Displaying of PHP errors

When this is on and something breaks, Moodle prints the raw technical error straight onto the page. That error can reveal your file paths, database details, even fragments of code, to anyone who happens to trigger it: a roadmap for an attacker. On any live site this should be off. Fix it under Site administration > Development > Debugging: set Debug messages to NONE for production, and make sure Display debug messages is unticked.

2. Allow EMBED and OBJECT

Moodle’s own description calls this “very dangerous”, and it means it. With <embed> and <object> tags allowed, any user who can add content can paste in markup that runs code inside other people’s browsers, a cross-site scripting attack. Unless you have a very specific reason and you trust every single account on your site, leave this off. (This is the same RISK_XSS family that the “XSS trusted users” check counts.)

3. Open to search engines

This setting lets Google and other crawlers walk into your courses as a guest. On its own that might be harmless, but combined with guest access switched on, it can quietly publish course content you assumed was private to the entire internet. If you do not specifically need public, crawlable courses, turn it off. The security report links straight to the setting.

4. Default role for all users, and Site home role

This is the subtle one. The security report has two related checks, “Default role for all users” and “Site home role”. Every logged-in person on your site inherits the capabilities of these roles. So if a risky capability ever creeps in here, you have not handed it to one user, you have handed it to everyone with an account. Open each of these roles (under Define roles) and confirm nothing dangerous is allowed; they should be kept to the bare minimum.

5. Secure cookies

If your site runs on HTTPS, and it absolutely should, the Secure cookies setting tells the browser never to send the session cookie over an unencrypted connection. That closes a real session-hijacking gap. Turn it on once your site is fully HTTPS. (Enabling it on a site still partly served over HTTP can lock users out, so make the HTTPS move first, then set this.)

Make this a habit

These five are the common offenders, but the Security checks report covers more: writable config, the guest role, password policy, antivirus, and others. It re-runs every time you open it, so make a habit of checking it after any upgrade or configuration change. A couple of minutes on this page leaves your site meaningfully harder to attack.

Solin hardens and audits Moodle installations for organizations that take security seriously. Contact us for a security review.