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.

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.

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.

Someone has told you to make your Moodle “GDPR compliant”, and most of what you find online is legal theory. This guide skips that and gives the concrete list of things you, as the administrator, actually set up and operate inside Moodle. There are five, and Moodle has a dedicated tool for each. Everything below lives under Site administration > Users > Privacy and policies, powered by two core tools, tool_dataprivacy and tool_policy. Paths and behavior are for Moodle 4.5. None of this is legal advice; it is the operational checklist that sits underneath whatever your DPO decides.

Prefer to watch? Here is a short walkthrough of the Moodle GDPR compliance setup:

1. Decide who is responsible: a Privacy Officer role

First, decide who handles data requests, because GDPR gives you a fixed deadline to respond and someone has to own that clock. Create a Privacy Officer role (base it on the Manager archetype) and grant it the data privacy capabilities your process needs, in particular tool/dataprivacy:managedatarequests and tool/dataprivacy:managedataregistry, plus the download and make-requests-for-children capabilities if you use them. Then assign it to a real person at site level. The step that is easy to miss: tick that role under Data privacy settings > Privacy officer so the holders are actually emailed when a request arrives, and turn on Contact the privacy officer. That second toggle does more than add a contact link: it is what makes the Export all of my personal data and Delete my account options appear on each user’s profile, under Privacy and policies. Leave it off (which is the default) and your users have no way to file their own request; only an admin or privacy officer can raise one on their behalf from the Data requests page. Sensible default: enable it, and name one privacy officer plus one backup so requests do not stall when someone is on leave. Skip all of this and a site admin can still process requests (Moodle does not lock you out), but with no named owner they are more likely to be missed or handled late, which is itself a compliance risk.

2. Declare why and how long: the data registry

The Data registry (/admin/tool/dataprivacy/dataregistry.php) is where you record, in Moodle’s own terms, what personal data you hold, why you are allowed to hold it, and how long you keep it. This is the documentation auditors and data protection officers ask for, and it also drives Moodle’s retention handling. It is built from two pieces: categories, which label a type of data, and purposes, which state the reason you process it and its retention period. You set a default category and purpose for the whole site and can override them lower down, per course category, course, activity, or block.

The real decisions live in the purpose. When you create one, you fill in:

  • Lawful basis (GDPR Art. 6): why you are legally allowed to process the data. For workplace training this is usually Legal obligation (you must be able to evidence mandatory training) or Contract (training forms part of employment). A public body might use Public task; genuinely optional data, such as a marketing preference, uses Consent.
  • Retention period: how long you keep the data. When it expires, Moodle flags the data and lists it for deletion for an administrator to confirm, so nothing is destroyed silently. For a user, the period is counted from the last time they accessed the site.
  • Protected: tick this for data you are legally required to keep, so a right-to-be-forgotten request cannot remove it before the retention period ends.
  • Sensitive data reasons (GDPR Art. 9): only needed if you hold special-category data such as health information.

If you are not sure where to start, a sensible default for a typical workplace-training site is one category, Learner records, and one site-wide purpose, Training records, with lawful basis Legal obligation and a retention period that matches how long you must be able to prove compliance (for example, three years since the learner’s last access). Mark certification or mandatory-training evidence as Protected so it survives an erasure request until that period is up. Then make that category and purpose the site-wide default: on the Data registry page the tree’s top node, Site, is selected for you, so choose your category and purpose there and click Save changes, making sure both selectors show an actual value and not Not set before you save. Do this on the Site node itself, not via the separate Set defaults button: that button only covers course categories, courses, activities, and blocks, so it never sets the site-level default. This matters before you handle any requests. Until a site default purpose is set, the Data requests page shows a red “A site purpose and category have not been defined” warning, and an erasure request would remove all of a user’s data instead of expiring only what is past its retention period. Retention itself is enforced on the separate Data deletion page (/admin/tool/dataprivacy/datadeletion.php), which lists contexts that are past their retention period and waits for your confirmation before the “Delete expired contexts” scheduled task removes the data.

3. Operate the data request queue (the part GDPR requires)

This is the heart of it. Under Data requests (/admin/tool/dataprivacy/datarequests.php) you will find the queue. A user clicks “Data request” in their profile to either export their personal data or have it deleted, and it lands here for your Privacy Officer to action.

  • Export request: approve it, and Moodle assembles a downloadable archive of the personal data it can reach through its privacy system. How complete that archive is depends on plugin privacy support, which is point 4. This is your right-of-access answer.
  • Erasure (delete) request: approve it, and Moodle runs its deletion workflow for that user’s personal data, within the limits of core and plugin privacy support. The right to erasure is not absolute, so your DPO still decides whether a given request should be granted. This is your right-to-be-forgotten answer.

Both are built in; your job is to make sure someone owns the queue and works it within the legal time limit, which under GDPR is one month. Sensible defaults: leave automatic approval off, at least for deletions, so a human reviews each erasure before any data is destroyed, and set a Data request expiry so completed export downloads do not stay available indefinitely.

4. The trap almost everyone misses: the plugin privacy registry

The Plugin privacy registry (/admin/tool/dataprivacy/pluginregistry.php) lists every plugin on your site and whether it properly implements Moodle’s Privacy API. Here is the catch: if a non-compliant plugin stores personal data, that data is not included when you export someone’s data, and it is not removed when you process an erasure request. Personal data can therefore survive quietly inside that plugin, and you would never know it from the request itself. There is nothing to fill in here; it is a review, and one you should repeat after every plugin install or upgrade. Sensible default: treat “does not implement the Privacy API” as a blocker for any plugin that stores personal data, and prefer an alternative that does, because a non-compliant plugin quietly undermines both of the rights in point 3.

5. Publish policies and record consent

Finally, publish your policies and capture consent. One setting makes or breaks this: under Policy settings, set the Site policy handler to Policies (tool_policy). Until you do, the tool stores nothing and no consent is enforced, which is the usual reason “I created a policy but nobody is asked to accept it.” Then, under Policies (/admin/tool/policy/managedocs.php), create each document with a name, a type (Site policy, Privacy policy, Third parties, or Other), a short summary, the full text, and an audience (usually All users). Set the status to Active and users must consent at their next login, with each acceptance recorded against a policy version and timestamp as your evidence. Sensible default: publish at least a Privacy policy and a Site policy aimed at All users; later, when you edit one, mark trivial wording fixes as a Minor change so you do not force everyone to re-consent unnecessarily. One caution: Moodle’s recorded policy acceptance is not the same thing as GDPR consent as a lawful basis. Plenty of processing relies on contract, legal obligation, or legitimate interests instead, so your DPO still decides the lawful basis for each purpose (point 2).

Recap

An owner, retention defined in the data registry, the request queue, the plugin privacy check, and policies with tracked consent. Keep these five operational and you have covered the main Moodle-side admin workflows that support GDPR compliance. The lawful basis for each purpose, your retention policy, processor agreements, external systems, backups, and the final decision on each request remain your DPO’s responsibility.

What this does not cover

This checklist covers Moodle’s built-in operational tools only. It does not replace a GDPR review of your lawful bases, your privacy notice, processor agreements, your hosting setup and backups, or any external plugins, LTI tools, analytics, video platforms, or custom integrations that handle personal data outside these screens. Setting up these five areas is the Moodle-side groundwork; it is not, on its own, a statement that your site is GDPR compliant.

Solin helps organizations in regulated sectors operate Moodle and Totara in a GDPR-aligned way, including privacy API gaps in third-party plugins. Contact us for a review.

A learner finishes a SCORM module and the player shows it as completed, yet Moodle leaves the activity (and therefore the course) incomplete, blocking certificates and compliance reporting. The cause is almost always a mismatch between the runtime status the SCORM package reports and the status the activity’s completion condition requires, compounded by the fact that course completion is recalculated on a schedule, not instantly. This guide explains the three layers of “complete” in Moodle, how to see what your package actually reported, and how to fix the mismatch. Paths and SQL are for Moodle 4.5.

Prefer to watch? Here is a short walkthrough of the completion mismatch and how to fix it:

The three layers of “complete”

The word “complete” means three different things at three different layers, and a learner is only finished when all three agree:

  1. The SCORM runtime status – what the package itself reports back to Moodle (for example cmi.core.lesson_status = completed).
  2. Activity completion – Moodle’s own decision about whether the SCORM activity is complete, based on the conditions you configured on that activity.
  3. Course completion – whether the course is complete, based on the activities and criteria you nominated, recalculated by a scheduled task.

A breakdown at any layer stops the chain. The most common failure is between layers 1 and 2.

Layer 1: what the SCORM package reports

SCORM 1.2 and AICC report a single field, cmi.core.lesson_status, which can be passed, completed, failed, incomplete, browsed, or not attempted.

SCORM 2004 splits this into two separate fields: cmi.completion_status (completed / incomplete) and cmi.success_status (passed / failed). This split matters: in Moodle, for a SCORM 2004 package the only status treated as complete is cmi.completion_status = completed. A package that reports success_status = passed but leaves completion_status at incomplete will never satisfy a completion-status condition.

The quickest way to see exactly what your package reported is in the interface: open the SCORM activity, go to Reports, click the learner’s attempt, and open Track details. The reported status is shown there. If you would rather query it directly, the tracking tables in Moodle 4.5 are:

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

(These tables were restructured in Moodle 4.x; on 4.0 and earlier the equivalent data lived in a single mdl_scorm_scoes_track table.)

Layer 2: the SCORM activity’s completion conditions

A SCORM activity offers three completion conditions in the Activity completion section of its settings:

  • Require status – a set of checkboxes (Passed, Completed, Failed, Incomplete, Browsed, Not attempted). The activity is marked complete when the reported status matches at least one of the ticked statuses.
  • Require minimum score – the activity is complete only when the SCORM score reaches the value you set.
  • All SCOs must return completion status – every SCO in a multi-SCO package must report a status, not just the launch SCO.

This is where the mismatch lives. If your package reports completed but Require status has only Passed ticked, the condition is never met and the activity never completes, even though the player clearly showed “completed”. The reverse is equally common: a package reports passed, but only Completed is ticked.

You can confirm the resulting activity-completion state per learner:

SELECT cmc.userid, cmc.completionstate
FROM mdl_course_modules_completion cmc
JOIN mdl_course_modules cm ON cm.id = cmc.coursemoduleid
WHERE cm.instance = ?  -- the SCORM instance id
  AND cm.module = (SELECT id FROM mdl_modules WHERE name = 'scorm');

completionstate is 0 (incomplete), 1 (complete), 2 (complete with pass), or 3 (complete with fail).

Layer 3: course completion and the recalculation lag

Course completion is configured under Course > More > Course completion. To make the SCORM activity count, add it under Condition: Activity completion and check whether the overall condition requires all selected activities or any of them.

Two things commonly trip people here:

  • The SCORM activity is complete, but it was never added as a course-completion criterion, so the course can never complete on the back of it.
  • The activity is complete, but the course still shows incomplete for a while. Course completion is not recalculated the instant an activity completes; it is processed by the scheduled task \core\task\completion_regular_task. Until the next run (or the learner reloads the course), the course status lags behind the activity status.

You can see which activities the course depends on:

SELECT course, criteriatype, moduleinstance
FROM mdl_course_completion_criteria
WHERE course = ?;

A criteriatype of 4 is an activity-completion criterion; moduleinstance is the course-module id of the activity.

Fixing it

  1. Match the required status to what the package reports. Open the SCORM activity, go to Activity completion, and under Require status tick the status your package actually sends (confirmed in Track details or the query above). If you are unsure what it sends, tick both Passed and Completed; the learner only needs to satisfy one of them.
  2. For SCORM 2004 packages, ensure the package sets cmi.completion_status = completed. If it only sets success_status, the completion-status condition will not be met; either fix the package or base completion on the minimum score instead.
  3. Check the “All SCOs” condition. If the package has multiple SCOs (for example a menu plus content SCOs) and one never reports a status, untick All SCOs must return completion status or fix the package so every SCO reports one.
  4. Add the activity to course completion and confirm the any/all aggregation matches your intent.
  5. Allow for the recalculation lag, or trigger it. After fixing the conditions, course completion updates on the next cron run. To verify immediately, run cron (php admin/cli/cron.php) or have the learner reload the course.

Re-checking existing learners

Changing a completion condition does not always retro-apply cleanly to learners who already attempted the activity. After adjusting the condition, use Reports > Activity completion and Course completion to confirm the affected learners now show complete, and run cron so course completion catches up. For a learner whose attempt predates the fix, a fresh review of completion (or, in stubborn cases, re-marking the attempt) may be needed.

Related

If your problem is a numeric pass/fail rather than a status mismatch (a learner sees a passing score on screen but Moodle records a fail), that is a different failure mode caused by grade rounding. See our guide on SCORM and quiz grade rounding for that case.

Solin specializes in Moodle and Totara completion, SCORM, and certification workflows. Contact us if a completion problem is blocking your reporting.

An auditor asks you to prove that everyone completed their mandatory training. Not “we delivered it”, but proof, per person, that stands up when they pull a sample at random. The good news is that Moodle already holds everything you need; the skill is knowing which report to pull, and in what form an auditor will actually accept it. This guide covers the four evidence sources and what makes each one defensible. Paths and behavior are for Moodle 4.5.

Prefer to watch? Here is a short walkthrough of pulling audit-ready completion evidence:

What “audit-ready” means

Before pulling anything, it helps to know the bar. Evidence that auditors accept is:

  • Dated – undated records are the single most common thing auditors reject.
  • Granular – down to the individual person and what they did.
  • Exportable – they will want a copy, not a screen.
  • Inclusive – it covers everyone in scope, including contractors and temporary staff, not just permanent employees.

Each source below contributes to that picture.

1. The course completion report (your primary evidence)

Start with Reports > Course completion. For every learner it shows whether they met the requirements and, crucially, when. Use the Download in spreadsheet format option at the foot of the report to export who completed what and on which date. That export is your primary evidence pack. (If completion tracking was never switched on for the course, this report is empty; set that up first, because retro-dating completions is not something an auditor will accept.)

2. Activity logs (corroboration for a sample)

Auditors sample, so be ready to back any single record up. The Logs report (Reports > Logs) gives dated, per-user activity: when each person accessed the material and when they submitted. Filter it to the course and to the specific people in the auditor’s sample, and you have corroboration for the completion record straight from the system’s own audit trail.

One caveat worth stating up front: Moodle’s standard logs are subject to a retention period set under Site administration > Plugins > Logging > Standard log, the “Keep logs for” setting. If your audit window is longer than that retention, either extend the retention before the period elapses or export logs periodically. Do not assume two-year-old logs are still there.

3. Grade export (proof of the assessment result)

If the training carries an assessment, export the grades too: Grades > Export > Excel spreadsheet. Now your evidence pack also shows the score each person achieved and that they passed, dated alongside the completion record. This matters where “attended” is not enough and the standard requires a demonstrated pass mark.

4. Verifiable certificates (the strongest single artifact)

The most defensible single artifact is a certificate the auditor can verify independently. Moodle core does not ship a certificate activity, but the widely used Custom certificate plugin (mod_customcert) does, and it issues certificates with a verification code. The certificate records the learner’s name, the course, and the date; the auditor goes to the certificate verification page, types in the code, and Moodle itself confirms the certificate is genuine and who it belongs to. That is about as defensible as evidence gets, because it does not rely on your word.

If you do not have a certificate plugin installed, the completion report plus logs and grades is still a complete evidence pack; the certificate is the icing, not the cake.

Putting the pack together

For a typical audit response: export the course completion report as your headline evidence, keep the grade export alongside it if there is an assessment, and be ready to produce filtered logs and a verified certificate for any name the auditor samples. Make sure the scope of the export includes contractors and temporary accounts. Dated, granular, exportable, inclusive: Moodle holds all of it, you just need to know where to look.

Solin helps regulated organizations build audit-ready evidence workflows in Moodle and Totara, including ISO 27001 and sector compliance. Contact us to talk it through.

Every plugin you add to Moodle is code written by someone outside the Moodle project, and someone has to keep updating it for each new Moodle version. When they stop, that plugin quietly becomes your problem: it can break on your next upgrade, and worse, it stops receiving security fixes. Here is how to check your whole site in about two minutes, and how to judge whether a plugin is genuinely abandoned or just stable. Paths and behavior are for Moodle 4.5.

Step 1: find your third-party plugins

Go to Site administration > Plugins > Plugins overview (/admin/plugins.php). This lists every plugin installed, grouped by type. The ones that are not part of the standard Moodle distribution are your third-party additions (the overview groups these as Additional plugins); those are the ones whose maintenance is not the Moodle project’s responsibility.

Step 2: read the update signals

If a plugin is flagged under Available updates, that is actually good news: it means the plugin is still being looked after, so you simply update it. Use the Check for available updates button to refresh that list. The ones to pay attention to are the additional plugins with no update available and a version that has not moved in a long time. The version string and release date shown here are your first clue, but they are not the whole story; for that, go to the source.

Step 3: check the plugin in the Moodle directory

The authoritative “is it abandoned” signal is the plugin’s page in the Moodle plugins directory at moodle.org/plugins. Open it and look at three things:

  1. The date of the latest release. Years since the last release is a warning sign.
  2. The supported Moodle versions. Does it even list your version? A plugin that does not claim support for your Moodle is running on borrowed time.
  3. How many sites still use it. Usage trailing off, combined with the above, points to abandonment.

A plugin whose last release was years ago, that does not support your Moodle version, with usage falling away, is effectively abandoned, even if it still happens to run today.

Step 4: decide what to do

Once you have found an abandoned plugin you have three choices:

  • Update it if a newer version exists (the easy case).
  • Replace it with a maintained alternative that covers the same need.
  • Remove it entirely if you no longer rely on it.

The one thing you should not do is leave abandoned, unpatched code running on a site that holds real people’s data. Before removing or replacing a plugin that stores data, check what happens to that data (and whether it implements the Privacy API) so you do not strand personal information or break existing courses. Do this check before every major Moodle upgrade, not just when something breaks; an unsupported plugin is the most common reason an upgrade stalls.

Solin manages Moodle and Totara plugin estates, upgrades, and third-party code audits. Contact us if you would like a plugin health check.

You opened Site administration > Reports > Security overview, and one row is sitting on a yellow Warning: “XSS trusted users”, with a number next to it. It reads like an alarm. It is not. Nobody has been hacked, and nothing is broken.

That row is a trust roster. It tells you how many people on your site hold a permission powerful enough that, in the wrong hands, it could be misused. Your job is not to make the number zero. Your job is to look at the list and confirm that everyone on it is someone you actually trust. This guide walks through exactly how to read that warning and act on it, step by step, using only the Moodle admin screens. No database queries, no command line. Paths and behavior are for Moodle 4.5.

Prefer to watch? Here is a short walkthrough of reading and acting on the dangerous capabilities warning:

What the warning actually means

Every permission in Moodle (every capability) can carry one or more risk flags. They are advisory labels, not faults. There are six:

  • XSS – the user can submit content that Moodle does not clean, such as HTML with active scripting or unchecked files. This is the one the “XSS trusted users” check counts.
  • Configuration – the user can change site-wide settings.
  • Personal – the user can reach other people’s private data.
  • Spam – the user can put content in front of others, or message them.
  • Data loss – the user can destroy large amounts of data that is hard to recover.
  • Manage trust – the user can manage other users’ trust settings.

A capability flagged XSS is not a vulnerability. It is simply powerful: anyone who can author rich content (paste HTML, embed media, restore a course, manage roles) can in principle insert a script. That is normal and necessary. The point of the flag is so you can reason about who holds that power.

The most useful fact to keep in mind is how Moodle assigns these risks by default:

  • Guest holds capabilities with no risk at all.
  • Student adds Spam.
  • Teacher adds Personal and XSS.
  • Manager and Administrator effectively hold everything.

So your administrators, managers, and teachers are supposed to be on the XSS list. A non-empty list is the correct, healthy state. The warning is really one question: is everyone here someone I trust to post rich content?

Step 1: Read the list

Go to Site administration > Reports > Security overview and click XSS trusted users (or More info on that row).

Moodle now shows you every user who holds an XSS-flagged capability, anywhere on the site, each name linked to their profile. The page states the task plainly: verify the list and make sure you trust these people completely. Read the names. This is the audit.

Step 2: Decide who belongs

For each person, ask one question: should this account be able to author rich content, manage roles, or restore courses?

  • Expected, leave them alone: administrators, managers, teachers, course creators, and any content-author role you deliberately created.
  • Investigate: an account that should never author content (a plain learner, a generic “info” or service account), an unfamiliar name, or anything that looks automated or left over.

There is also a list-length signal. If the roster is dramatically longer than the number of staff who should have content power, a risky capability has almost certainly leaked into a role that is handed to many people at once, or a staff role has been assigned far too broadly. Hold that thought for Step 5.

Step 3: Find out why a user is on the list

This is the step most write-ups skip, because Moodle has no single screen that says “this user is risky because of capability X in role Y.” You assemble it from two screens, and it takes about thirty seconds per person.

From the list in Step 1, click the user’s name to open their profile, then go to Preferences > This user’s role assignments (under the Roles heading).

This page shows every role the user holds and the exact context for each one: system, a category, or a single course. It is where the cause usually jumps out, for example a support-style or helpdesk-style role assigned at system level, a Teacher or Manager role assigned site-wide instead of inside one course, or a custom role that grants far more than its name suggests.

A teacher assigned inside one course is exactly where they should be. A “support” role sitting at the top, system level, is the kind of thing this audit exists to catch.

Step 4: Confirm which capability is responsible

Open the role you just identified: Site administration > Users > Permissions > Define roles, then open that role.

On the capability table, the Risks column on the right shows a small icon against every capability that carries a risk. Hover an icon to see its label; the one marked XSS risk is your confirmation that this is what put the user on the list. Reading down that column tells you, at a glance, how much power the role really grants, which is often far more than its name implies.

If you would rather work the other way around, Site administration > Reports > Capability overview lets you pick a specific capability and see which roles grant it across the whole site. That is the fastest way to answer “which roles hand out this exact permission?”

Step 5: Fix it the right way

You have three clean options. Choose by what is actually wrong, and follow the principle of least privilege: give each person the least power that lets them do their job.

  1. The person should not have that power. Remove them from the role, or narrow the assignment to the correct context. You can do this from the user’s role-assignments page or under the role’s Assign roles tab.
  2. The role should not grant that capability. Edit the role (Define roles > [role] > Edit) and set the offending capability to Not set or Prevent, then save. This affects everyone who holds that role, so make sure none of them need it.
  3. The capability is on a role given to everyone. This is the most common cause of a bloated list, and the most urgent. Check the Authenticated user, Guest, and Front page roles first: a single risky capability there is handed to your entire user base at once. These roles should never carry a dangerous capability.

One thing not to do: do not strip XSS capabilities from your standard Teacher role just to clear the warning. Teachers are on the list by design, and removing those capabilities breaks legitimate content authoring. If a particular teacher should be more limited, narrow where they are assigned rather than rewriting the shipped role. Editing default roles globally also wipes any course-level overrides your teachers rely on, so prefer a context-specific override or a purpose-built custom role.

Step 6: Verify, then re-check

Confirm the change did what you intended before you move on. In the relevant context (a course’s Participants > Permissions > Check permissions, or the system-level Check permissions screen), look up the user and confirm they can, or can no longer, do the thing you changed. This shows you the computed result rather than your assumption of it.

Then re-run Reports > Security overview. The XSS trusted users count should now contain only people you trust. Again: the target is not zero. The target is no surprises.

A note on “trusted content”

If you want to understand why XSS is a risk category at all, it comes down to how Moodle cleans HTML. By default Moodle strips active content from what users submit. There is an optional setting, Enable trusted content (under Site administration > Security), that, together with the moodle/site:trustcontent capability, lets named users save HTML that bypasses that cleaning.

It is off by default, and that is the right default for almost everyone. Leave it off unless you have a concrete reason to turn it on, and if you do, grant moodle/site:trustcontent to the smallest, most trusted group possible, because it widens exactly the list you just audited. For extra hardening in the other direction, the $CFG->forceclean setting forces content cleaning everywhere.

The same habit covers the whole report

The XSS trusted users check is one of several access checks in the Security overview, alongside Administrators, Guest role, Default user role, Front page role, and Backup data access risk. They all answer the same kind of question: are these settings, and these people, what I expect? Treat the security report as a periodic review rather than a one-time cleanup, and run through it after any change to roles or permissions.

Quick checklist

  1. Reports > Security overview > XSS trusted users: read the list.
  2. Tick off the people who should be there (admins, managers, teachers, content authors).
  3. For anyone who looks wrong: Profile > Preferences > This user’s role assignments to find the role and context.
  4. Define roles > [role]: read the Risks column to confirm the capability, or use the Capability overview report.
  5. Fix by least privilege: unassign the person, narrow the context, or prevent the capability. Check blanket roles (Authenticated user, Guest, Front page) first.
  6. Check permissions to verify, then re-run the Security overview. Aim for “only trusted people”, not zero.

Solin specializes in Moodle role and permission security. Contact us for a roles and capabilities audit.