Guide
Drupal user.role_changed — detecting privilege escalation
1. Problem
You log into your Drupal site and notice an account you don't recognise sitting in the administrator role. Or worse: an account you do recognise — an old editor, a former contractor, a content manager — now has full admin rights. Nothing else looks broken. The site is up. Pages render. Cron is running. There is no error banner.
You search "drupal user role changed without my knowledge" and find advice that mostly amounts to "check the People page and revoke the role." That fixes the symptom. It does not tell you when the change happened, who made it, or whether the same actor is still active.
If you are searching for "drupal new admin role detect" or "drupal privilege escalation audit," you are already past the point where Drupal's UI helps you. The People list shows the current state, not the history. And by default, Drupal does not email you, does not log to syslog, and does not flag a role grant as anything more interesting than a content edit.
This is the failure mode this guide addresses: a role was granted, the system worked exactly as designed, and you only noticed by accident.
2. Impact
A role change on a CMS is rarely interesting on its own. It becomes interesting when it is the second step of an attack chain.
The first step is account compromise — credential stuffing, a leaked password, an XSS-stolen session cookie, or a vulnerable contrib module. The second step is persistence: the attacker grants admin rights to a second account they control, so that even if you reset the password on the originally compromised account, they keep their foothold.
Concrete consequences of a missed role grant on a Drupal site:
- Backdoor admin accounts. A sleeper account sits dormant for weeks, then logs in once and exfiltrates the user table or installs a malicious module.
- Content tampering. Promoted users can publish, unpublish, or edit nodes that bypass editorial workflows.
- Module installation.
administer modulespermission lets an attacker install a contrib module — or a malicious patched version — that ships PHP code execution as a feature. - User enumeration and PII exfiltration.
administer usersexposes email addresses, last login timestamps, and role assignments for every account. - Compliance exposure. For sites under GDPR, HIPAA, or SOC 2 scope, an undocumented privilege change is itself a reportable event regardless of whether data was touched.
The real cost is dwell time. The longer the role grant goes unnoticed, the more the blast radius grows.
3. Why It’s Hard to Spot
Drupal's defaults work against you here.
Watchdog is too quiet. The default dblog entry for a permission change is something like Roles changed for user TestUser. There is no diff, no actor reference beyond the current user context, and no alert. It sits in the same /admin/reports/dblog page as every cron run, every cache rebuild, and every 404.
The People page shows current state, not history. If you check /admin/people today, you see who is an administrator now. You cannot see who became an administrator yesterday, or last month, or during the breach window.
Email notifications are off by default. Drupal core does not email site admins when role membership changes. You can install Login History or User Activity Tracker, but most sites don't.
Bulk actions hide individual changes. Granting the editor role to 50 users via a bulk operation produces one log line per user, buried among legitimate activity.
Programmatic changes leave no UI footprint. A drush command, a hook_update_N migration, or a malicious module that calls $user->addRole('administrator')->save() does not appear in /admin/reports/dblog at all unless the calling code explicitly logs it.
Uptime monitors don't care. Pingdom, UptimeRobot, StatusCake — they all return 200 OK while your privilege model silently changes underneath them.
This is the textbook silent failure: nothing crashes, nothing alerts, and the only path to discovery is a manual audit nobody schedules.
4. Cause
In Drupal, role membership is stored in the user__roles table — one row per (user, role) pair. The change happens through UserInterface::addRole($rid) followed by User::save(), which fires the hook_user_update and hook_ENTITY_TYPE_update lifecycle. Drupal core writes a watchdog entry to the dblog channel only if the change came through /admin/people/permissions or a bulk operation — and even then, it logs the action, not the diff.
A user.role_changed signal in Logystera represents exactly this transition. The agent module hooks hook_user_presave and compares the original entity to the new one. When the role set differs, it emits one signal with three fields that matter:
actor_uid— the user who performed the change (from the current session, not the target)target_uid— the user whose roles were modifiedrole_added/role_removed— the specific role machine names (e.g.administrator,editor)
The signal is emitted regardless of how the change happened: UI, Drush (drush user:role:add), REST API (PATCH /user/{uid}), JSON:API, a custom form, a migration, or a malicious module calling $user->addRole() directly. That coverage is the point. The Drupal admin UI logs only the UI path. user.role_changed covers all paths because it lives in the entity lifecycle.
When you see user.role_changed with role_added: administrator and an actor_uid you don't recognise — or with actor_uid == target_uid (a user granting themselves a role, which should almost never happen outside initial setup) — you are looking at the exact moment privilege escalation occurred.
5. Solution
5.1 Diagnose (logs first)
You need to answer four questions in order: when, who, what, how. Each maps to a different log source.
When and what — user.role_changed signal
The fastest path is the user.role_changed signal itself. In Logystera, filter the entity timeline for signal type user.role_changed and look at the role_added field:
event_type: user.role_changed
role_added: administrator
This gives you the timestamp and the target account. Without Logystera, the closest equivalent is dblog:
drush sql:query "SELECT timestamp, message, variables, uid FROM watchdog \
WHERE type = 'user' AND message LIKE '%Roles%' \
ORDER BY timestamp DESC LIMIT 50"
Convert timestamps with date -d @. The variables column is a serialised PHP blob — unpack it to see which roles were involved.
Who — actor correlation via auth.login_success
A role change is performed by someone. The user.role_changed signal includes actor_uid. Pivot to that user's recent logins:
event_type: auth.login_success
uid: <actor_uid>
You are looking for the IP address, user agent, and timestamp of the session that performed the grant. If actor_uid is 1 (the superuser) and you are the only person with uid 1, but the IP is from a country you've never been to, that is your incident.
In raw Drupal logs:
drush sql:query "SELECT timestamp, hostname, message FROM watchdog \
WHERE type = 'user' AND message = 'Session opened for %name.' \
ORDER BY timestamp DESC LIMIT 20"
How — http.request correlation
Cross-reference the user.role_changed timestamp with web access logs to find the exact request:
grep -E "POST /admin/people/permissions|POST /user/[0-9]+/edit" \
/var/log/nginx/access.log | awk '$4 > "[14/Apr/2026:10:00"'
A POST /admin/people/permissions/RID produces one http.request signal. A POST /user/N/edit (where N is the target uid) produces another. If neither appears in the access log within seconds of the user.role_changed timestamp, the change came through a non-HTTP path: Drush, a console command, or a module's update hook. Check shell history and recent deployments.
Cross-checking with watchdog
For belt-and-braces, dump everything that happened in the suspect window:
drush watchdog:show --count=200 --severity=Notice \
--type=user --extended | grep -i "role"
Each grep and signal pivot above answers a piece of the puzzle. The user.role_changed signal is the anchor. auth.login_success answers who. http.request answers how. watchdog is the corroborating record.
5.2 Root Causes
(see root causes inline in 5.3 Fix)
5.3 Fix
Fixing a privilege escalation is a sequence, not a single action. Doing it out of order leaves backdoors.
Cause 1 — Compromised admin account performed the grant
This is the most common case. The signal shape is: a user.role_changed event from a known-good actor_uid, but the preceding auth.login_success for that uid came from a hostile IP or unfamiliar user agent.
Steps:
- Force-reset the compromised actor's password:
drush user:password."$(openssl rand -base64 24)" - Invalidate sessions:
drush sql:query "DELETE FROM sessions WHERE uid =." - Revoke the unauthorised role on the target:
drush user:role:remove administrator. - If the target account itself was attacker-created, block and delete it:
drush user:blockthendrush user:cancel.--delete-content - Rotate any API keys, secret keys, or hash salts the actor had access to (
settings.php$settings['hash_salt']).
Each of these operations will itself emit signals — user.role_changed (for revocation), auth.logout, wp.state_change equivalents in Drupal terms. Verify they appear before declaring done.
Cause 2 — Vulnerable contrib module granted permissions
Some modules expose configuration that maps external IDs (LDAP groups, SSO claims, OAuth scopes) to Drupal roles. A misconfigured mapping can grant administrator to anyone with a valid token from an upstream IdP.
Signal shape: many user.role_changed events in a short window, all with the same actor_uid (the module's service account) or a synthetic uid like 0.
Steps:
- Identify the module:
drush pml | grep -E "ldap|simplesamlphp|openid|oauth". - Audit the role mapping configuration in the module's settings page or YAML config.
- Constrain the mapping to a less privileged role.
- Re-run
drush cranddrush updbif you patched code.
Cause 3 — Insider action (legitimate but undocumented)
A teammate granted a role through normal channels and forgot to tell anyone. Signal shape: user.role_changed correlated with auth.login_success from a known internal IP and a normal user agent.
Steps:
- Confirm the change with the actor through a side channel (chat, phone — not email, in case email is also compromised).
- Document the grant in your change-management system.
- If unauthorised, treat as Cause 1.
Cause 4 — Migration or update hook granted roles
A hook_update_N ran during deployment and granted roles to a population of users. Signal shape: a burst of user.role_changed events with actor_uid: 1 or no actor, timestamped within seconds of a deploy.
Steps:
- Diff
git log -p modules/custom/*/.installfor recentaddRolecalls. - If the grant was unintended, revert via
drush user:role:removeover the affected uids. - Add a deploy-time check that flags any update hook touching
user__roles.
5.4 Verify
You are looking for the absence of user.role_changed events that you cannot explain.
Healthy state:
- No new
user.role_changedsignals from unknownactor_uidvalues for at least 24 hours. - All
user.role_changedsignals in the last 7 days have a correspondingauth.login_successfrom a known-good IP for the actor. - No
user.role_changedevents whererole_addedisadministratoroutside of documented onboarding windows.
Verification commands:
# No admin grants in the last 24h?
drush sql:query "SELECT COUNT(*) FROM watchdog \
WHERE type='user' AND message LIKE '%administrator%' \
AND timestamp > UNIX_TIMESTAMP(NOW() - INTERVAL 1 DAY)"
# Current admin set — diff against your baseline
drush user:information --roles=administrator --format=json
Capture the second command's output as your new baseline (drush user:information --roles=administrator > admin-baseline.json) and diff it on every deployment.
Timeframe: if no unexpected user.role_changed signals appear over 72 hours of normal traffic, and every signal that did appear maps to a documented action, the immediate incident is contained. Containment is not the same as eradication — schedule a full session and credential rotation within seven days.
6. How to Catch This Early
Fixing it is straightforward once you know the cause. The hard part is knowing it happened at all.
This issue surfaces as user.role_changed.
The hard part of this failure was never fixing it. Revoking a role takes one Drush command. The hard part is knowing it happened.
Drupal will not tell you. Watchdog will whisper it into a log table no one reads. The People page will show the new state as if it had always been that way. Uptime monitors will show all green.
This class of failure surfaces as the user.role_changed signal — emitted on every grant or revocation across every code path: UI, Drush, REST, JSON:API, custom forms, update hooks, modules. Logystera detects it the moment it happens, correlates it with the actor's auth.login_success and the corresponding http.request, and alerts on the patterns that indicate compromise: privilege escalation to administrator, self-grants where actor_uid == target_uid, and bursts of role changes outside change-management windows.
The point is not that Logystera is the only way. You can build the same detection by hand with a custom module, a syslog forwarder, and a SIEM. The point is that the signal is the diagnostic primitive — once you treat role changes as first-class events instead of buried dblog rows, the whole class of "I didn't know that happened" incidents collapses into a single alert.
7. Related Silent Failures
Privilege escalation rarely arrives alone. The user.role_changed signal sits in a cluster of related Drupal-security signals worth detecting together:
auth.login_successfrom new geography — the precursor to most role-change incidents. An admin logging in from a country they have never logged in from before is a stronger signal than the role change itself.http.requestto/admin/modulesafter hours — module installation outside business hours, especially correlated with a recentuser.role_changed, indicates an attacker establishing persistence through code.user.createdfollowed byuser.role_changedwithin minutes — the classic backdoor pattern: create account, promote it, log out.watchdogseverity ≥ critical with typephp— fatal errors during admin sessions can indicate exploitation attempts; correlate with role changes in the same window.auth.login_successforuid: 1— direct logins as the superuser are almost always either initial setup or compromise. There is no middle ground in production.
Each of these is a separate guide. Together, they form the Drupal security cluster: one consistent way to read a CMS for the things it does not tell you about itself.
See what's actually happening in your Drupal system
Connect your site. Logystera starts monitoring within minutes.