Guide

Drupal theme switched unexpectedly — deployment marker, mistake, or compromise

You load the homepage and the site looks wrong. The header is in the wrong place. Custom branding is gone. The colours have reverted to a default palette.

1. Problem

You load the homepage and the site looks wrong. The header is in the wrong place. Custom branding is gone. The colours have reverted to a default palette. A content editor opens Slack: "Why does the site look like Bartik again?" Or it is the other way around — a Drupal 10 site that has been on a custom subtheme for two years suddenly renders in Olivero, and half the regions are empty because the custom theme had region machine names the new theme does not declare.

You go to /admin/appearance. The default theme is set to something nobody on your team picked. You did not deploy a theme change. There is no commit in config/sync/system.theme.yml matching this state. The active theme on the live site does not match the theme in your repo.

If you have searched "drupal theme changed unexpectedly", "drupal site layout reverted to bartik", or "drupal theme switched to olivero who did it" in the last hour, this is the failure mode. The Drupal admin shows you the current default — it does not tell you who switched it, when, or whether the switch was a config import, an admin form submission, or a direct write to system.theme. This usually surfaces as a drupal_structure_changes_total event with operation=theme_switch, immediately followed by a burst of drupal_block_change_total as block placements reshuffle.

2. Impact

A theme switch on a production Drupal site is one of the most visible structural failures the platform has.

  • Brand and revenue. Custom branding, hero sections, and CTA placements live in the theme. A site doing $40k/day in subscriptions saw conversions drop 28% in the four hours their theme was reverted to Olivero — the signup CTA was rendered by a custom region the new theme did not declare.
  • Region and block loss. Custom themes declare regions like header_top, sidebar_promo, footer_legal. When the active theme changes, every block placed in a region the new theme does not declare becomes orphaned. The block configuration still exists; the block does not render anywhere. Editorial work spanning months goes invisible in a single save.
  • Content that depends on theme functions. Custom Twig templates, preprocess hooks, and theme-supplied libraries vanish. A site relying on node--article--full.html.twig reverts to plain rendering, sometimes leaking field labels and metadata the custom template was hiding.
  • Security — the silent reason. Post-compromise reversion to a default theme is a known TTP. The attacker switches to a stock theme so their staged webshell at themes/custom//php-cgi.php is no longer scanned by the active-theme integrity check, while a fresh malicious file dropped under the new active-theme path rides legitimately. Reverting also disables custom security headers and CSP directives that lived in the previous theme's .libraries.yml.
  • Trust impact. Every page view during the regression is a customer seeing your site in a broken state. Support tickets land within minutes.

The window from "theme switched" to "customer noticed" is usually under five minutes. The window from "customer noticed" to "you understand what happened" is usually over an hour, because Drupal's audit story for theme changes is shallow.

3. Why It’s Hard to Spot

Drupal does not surface theme switches as security events. It treats them as ordinary configuration writes.

  • No native audit row with actor. The default dblog entry for a theme switch is a single row at severity info: "Set default theme to olivero." No actor, no IP, no request URI. If dblog is disabled (it is on many high-traffic sites because of write load), even that row is gone.
  • The admin shows current state only. /admin/appearance is a list of installed themes with one marked default. No "changed by" column, no timestamp, no diff. You cannot tell whether the current default has been default for two years or two minutes.
  • Uptime monitors miss it. A theme switch does not change HTTP status codes. The homepage returns 200. Synthetic checks pass. The only signal in the rendered HTML is that the markup looks different — and synthetic monitors do not diff markup.
  • Config import path is invisible. If CI runs drush config:import on every deploy, a tampered system.theme.yml in your repo enables a theme change as part of the import. No review step, no diff alert. The change rides legitimacy.
  • The post-compromise scenario hides in plain sight. An attacker with shell or admin access can switch the theme via drush cset system.theme default olivero in under a second. No HTTP request, no admin form submission, no failed login burst — just one DB write.

The combination — minimal native logging, no actor context, no diff view, no email alert — is what makes "drupal theme switched to olivero who did it" a question with no answer inside Drupal. The answer lives in logs, specifically in the drupal_structure_changes_total signal with operation=theme_switch.

4. Cause

When something changes the active theme — through /admin/appearance, through drush cset system.theme default , through drush config:import of a tampered system.theme.yml, or through a direct DB write to the config table — Drupal's configuration system fires a ConfigCrudEvent for the system.theme config object. The Logystera Drupal agent hooks ConfigEvents::SAVE and emits a drupal_structure_changes_total event with operation=theme_switch.

The event's labels include previous_theme, new_theme, actor_uid, actor_name, request_ip, request_uri, trigger (ui / drush / config_import / api), and timestamp. The supporting signal drupal_blocks_by_theme (metric ID 390) records the count of placed blocks per theme — within seconds of a theme switch, this metric reshuffles as Drupal's block layout system rebinds block placements to the new theme's regions. That reshuffle is the confirmation that the switch took effect, not just an attempted save. drupal_block_change_total fires for every block placement created, updated, or orphaned during the rebind.

This is the signal chain that answers "who switched the theme, when, from where, and did the change actually land."

5. Solution

5.1 Diagnose

Stop staring at /admin/appearance. Go to logs.

1. Drupal watchdog — the native breadcrumb, if it is on.

drush watchdog:show --type=system --severity=info --count=200 | grep -iE "theme|appearance|default"
drush sql:query "SELECT timestamp, uid, hostname, message FROM watchdog WHERE message LIKE '%theme%' ORDER BY wid DESC LIMIT 50"

This surfaces the same data the Logystera agent reads to produce drupal_structure_changes_total with operation=theme_switch. Note uid (actor) and hostname (request IP). If uid=0 and hostname is cli or a private IP, the switch did not come through a logged-in admin session.

2. Web server access logs — find the theme-switch HTTP request.

The UI path is POST /admin/appearance/default with the new theme machine name in the query string, or POST /admin/appearance from the theme listing form.

grep -E "POST /admin/appearance" /var/log/nginx/access.log | awk '{print $1, $4, $7, $9}'
grep -E "/admin/appearance/default|/admin/appearance/install" /var/log/nginx/access.log

Each match is a candidate http.request event that should correlate with the drupal_structure_changes_total theme_switch event by timestamp and actor IP. Time correlation is the load-bearing step here: if your drupal_structure_changes_total theme_switch fired at 14:03:21, search nginx logs in a 30-second window around that timestamp. A POST to /admin/appearance/default from an admin's known IP at 14:03:20 is a routine deploy. A POST from an unfamiliar IP at 14:03:20, or no POST at all, is the story.

3. Active config and the system.theme object.

drush config:get system.theme
diff <(drush config:get system.theme --format=yaml) config/sync/system.theme.yml

A non-empty diff is configuration drift. If active config differs from the repo's sync directory, the change happened outside your normal deploy path — and produces no drupal_deployment_events_total event in the Logystera stream.

4. Block placement reshuffle — confirmation that the switch landed.

drush sql:query "SELECT theme, COUNT(*) FROM block_content_field_data b JOIN config c ON c.name LIKE CONCAT('block.block.', b.id) GROUP BY theme"
drush config:list --filter=block.block | xargs -I{} drush config:get {} theme | sort | uniq -c

The drupal_blocks_by_theme metric (id 390) records this exact count. After a theme switch, expect a sharp drop in the previous theme's block count and a corresponding rise in the new theme's count. A burst of drupal_block_change_total events in the 60 seconds following the drupal_structure_changes_total theme_switch is the signature that the change went through and Drupal rebound block placements.

5. Drush / shell history — Drush switches do not produce HTTP requests.

grep -E "drush (cset|config:set).*system\\.theme" /home/*/.bash_history /root/.bash_history 2>/dev/null
grep -E "drush.*theme:default|drush.*then" /var/log/auth.log
last -F | head -20

If the switch came from the CLI, actor_uid=0 on the drupal_structure_changes_total event and request_ip=cli. That is materially worse than an admin doing something unexpected — it means somebody had shell on the server.

6. Correlate with deployment markers.

grep "drupal_deployment_events_total" /var/log/logystera/agent.log | tail -20

The drupal_deployment_events_total signal fires when CI runs drush config:import or drush updb in a documented deploy window. A drupal_structure_changes_total theme_switch paired with a drupal_deployment_events_total event in the same minute is a deploy. A theme_switch with no nearby drupal_deployment_events_total is either a manual UI change or a compromise.

5.2 Root Causes

Map the cause to the signal pattern. There are three legitimate switches and three suspicious ones.

Legitimate patterns:

  • Planned theme upgrade or rebrand. drupal_structure_changes_total with operation=theme_switch, trigger=config_import, paired with a drupal_deployment_events_total event in the same window and an actor_uid matching the deploy user. drupal_blocks_by_theme reshuffles cleanly to a pre-staged region map.
  • Subtheme parent swap. A custom subtheme is rebased on a new parent — surfaces as a theme_switch followed by a drupal_block_change_total burst as inheritance recalculates region assignments. trigger=ui from a senior admin during business hours.
  • A/B testing rollout. Some teams use config-driven theme switching for branding experiments. trigger=api with the actor_uid of the experimentation service account.

Suspicious patterns:

  • Compromised admin session. drupal_structure_changes_total with operation=theme_switch, trigger=ui, actor_uid of a real admin, preceded by auth.login_success from an unfamiliar IP — often preceded by a burst of auth.login_failed against the same username. The reversion to a stock theme (Olivero, Bartik, Claro) is the tell. Attackers don't pick custom themes; they pick the default to minimise their blast radius and hide their dropped files under a fresh active-theme path.
  • Drush / shell foothold. theme_switch with trigger=drush, actor_uid=0, request_ip=cli. No corresponding auth.login_success, no http.request to /admin/appearance, no drupal_deployment_events_total. This is server-level compromise.
  • Config import drift. theme_switch with trigger=config_import paired with a drupal_deployment_events_total event — but the config/sync/system.theme.yml in the repo's deployed commit was not what the team reviewed. CI ran a tampered config tree.

Each cause produces the same primary signal (drupal_structure_changes_total with operation=theme_switch); the trigger label and the surrounding signals tell them apart.

5.3 Fix

Cause: Compromised admin session. Invalidate all sessions (drush sql:query "TRUNCATE sessions"), rotate every admin password, enable TFA via the tfa module, audit users_field_data for accounts you did not create. Switch the theme back: drush cset system.theme default and drush cr. Then audit themes/custom/ and themes/contrib/ for files modified in the compromise window — find web/themes -newer /tmp/incident-marker -type f against a marker file you created at the start of the incident.

Cause: Drush / shell foothold. Server-level response. Rotate SSH keys, audit ~/.ssh/authorized_keys for every web user, review CI/CD deploy keys, check /var/log/auth.log for SSH access in the switch window. Restore themes/ from a known-good backup if integrity cannot be proven by hash. Switch theme back, then drush cr.

Cause: Config import drift. Review the imported diff. Check git history on config/sync/system.theme.yml for an unauthorized commit. Add a CI check that fails the pipeline if system.theme.yml changes without matching ticket metadata in the commit message.

Cause: Legitimate but unannounced. Document it. Add to the deploy log. Confirm the new theme's regions accommodate every existing block placement — orphaned blocks need a new region or a new theme that declares the old region machine names.

5.4 Verify

You are looking for two things: the absence of unauthorized drupal_structure_changes_total theme_switch events, and the absence of the access path that allowed them.

Signal that should stop appearing: drupal_structure_changes_total events with operation=theme_switch outside documented deploy windows. After the fix, expect zero such events in the next 72 hours of normal operation.

Expected baseline: for a healthy production Drupal site, drupal_structure_changes_total theme_switch is a near-zero metric — typically 0 events per month, with rare spikes during planned theme upgrades. If your baseline shows 2–3 per week, you have an unmanaged process. The healthy steady state is zero; any non-zero value should map to a known deploy ticket.

Supporting verification:

  • drupal_blocks_by_theme should be stable post-fix — no further reshuffles in the 30 minutes after the corrective drush cset.
  • drupal_block_change_total should return to its background rate (a few events per day for normal editorial work, not bursts of 50+ in 60 seconds).
  • diff <(drush config:get system.theme --format=yaml) config/sync/system.theme.yml should be empty.
  • No new auth.login_failed bursts against admin accounts.

If after 30 minutes under normal traffic you see no new drupal_structure_changes_total theme_switch events, drupal_blocks_by_theme is stable, and active config matches sync, the incident is contained. If drupal_structure_changes_total theme_switch fires again with actor_uid=0 and trigger=drush, the underlying access path is still open — go back to root cause B.

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 drupal_structure_changes_total with operation=theme_switch.

Everything you just did manually — diff system.theme against the repo, grep nginx for /admin/appearance POSTs, correlate the watchdog info row with auth.login_success, watch drupal_blocks_by_theme reshuffle to confirm the switch landed — Logystera does automatically. The same drupal_structure_changes_total event you just searched for is detected, charted, and alerted in real time, with previous_theme, new_theme, actor_uid, request_ip, and trigger attached.

!Logystera dashboard — drupal_structure_changes_total over time drupal_structure_changes_total operation=theme_switch event at 14:03, paired with drupal_blocks_by_theme reshuffle confirming the switch landed.

!Logystera alert — Drupal theme switched unexpectedly Critical alert fires within 60s of a theme_switch event outside a documented deploy window.

The fix is simple once you know the problem. The hard part is knowing it happened at all. Logystera turns a theme switch from a customer-reported emergency — "why does the site look like Bartik?" — into a 60-second notification with the actor, the IP, the trigger, and the block reshuffle confirmation that proves the change went through.

7. Related Silent Failures

See what's actually happening in your Drupal system

Connect your site. Logystera starts monitoring within minutes.

Logystera Logystera
Monitoring for WordPress and Drupal sites. Install a plugin or module to catch silent failures — cron stalls, failed emails, login attacks, PHP errors — before users report them.
Company
Copyright © 2026 Logystera. All rights reserved.