You add a data- attribute to a block. You save. You view the post. The attribute is there. Then another user opens the same post, changes a typo, saves, and the attribute is gone. The markup still renders, the block still works, but the data is missing. Nothing in the editor UI warned anyone.
This is not a block editor bug. It is KSES doing exactly what it was told to do, and the instruction set depends on who is saving.
Symptom
A user with the unfiltered_html capability saves a post containing markup like this:
<div class="callout" data-tone="warning" data-source="editorial">
Text here.
</div>
The post stores the data-tone and data-source attributes. The front end reads them. Everything works.
A user without that capability opens the same post, makes an unrelated change, and saves. The stored content now reads:
<div class="callout">
Text here.
</div>
The class survived. The data-* attributes did not. If you check the post revisions, the change is visible in the diff. If you check the database directly, the stripped version is what is stored.
Trace
Content passes through KSES on the way into the database. The relevant function is wp_kses_post(), which is a thin wrapper:
function wp_kses_post( $data ) {
return wp_kses( $data, 'post' );
}
That is the entire function, per the WordPress developer reference for wp_kses_post(). The second argument, 'post', is a context string. It does not mean “the post being saved.” It means “use the post-content allowlist.”
That allowlist is produced by wp_kses_allowed_html( 'post' ). The function is documented at wp_kses_allowed_html(). For the 'post' context, it starts from the global $allowedposttags, then runs the result through the wp_kses_allowed_html filter.
So the chain is: wp_kses_post() → wp_kses( $data, 'post' ) → wp_kses_allowed_html( 'post' ) → $allowedposttags plus any filter additions.
The question is not whether data-* attributes are in $allowedposttags. They are not, and they never have been. The question is why the first user’s save kept them anyway.
Root cause
KSES is not applied unconditionally. It is applied when the current user lacks the unfiltered_html capability. On a single-site install, both Administrators and Editors hold that capability by default. Authors, Contributors, and Subscribers do not. On multisite, only Super Admins hold it.
The capability check is what decides whether wp_kses_post() runs at all on the content being written. When the user has unfiltered_html, the content is stored as submitted. When the user does not, the content is passed through the post-context allowlist, and anything not in that allowlist is removed.
data-* attributes are not in the default post allowlist. The WordPress developer reference for the wp_kses_allowed_html filter notes that attribute names must be added in lowercase, and the filter is the documented extension point for adding tags and attributes to a context. There is no built-in wildcard for data-*. Each attribute you want to survive must be named, or you must change who is allowed to save unfiltered content.
This is why the symptom looks role-dependent rather than content-dependent. The same markup, saved by two different users, produces two different stored results. The post content is not the variable. The capability is.
Why the block editor does not save you
The block editor sends content through the REST API. The REST posts controller applies KSES on the way in, using the same capability gate. The block editor’s client-side validation does not add attributes to the server-side allowlist. If the attribute is not in the allowlist and the user lacks unfiltered_html, the REST save strips it just as the classic editor path would.
This matters because it is tempting to assume the block editor’s structured data model protects attributes. It does not. Block attributes stored in the block comment delimiter are a separate concern from HTML attributes inside block content. A data-* attribute written into raw HTML inside a block is subject to the same KSES pass as any other HTML.
Fix
There are two honest fixes. Pick based on whether you actually want the affected users to be able to write arbitrary HTML.
Option 1: Extend the allowlist for the post context
Add the specific attributes you need to the post context. Do not add a wildcard. KSES does not support wildcards, and trying to emulate one by allowing every possible attribute name defeats the purpose of the filter.
add_filter( 'wp_kses_allowed_html', function( $tags, $context ) {
if ( 'post' !== $context ) {
return $tags;
}
$data_attrs = array(
'data-tone' => true,
'data-source' => true,
);
foreach ( array( 'div', 'span', 'section' ) as $tag ) {
if ( ! isset( $tags[ $tag ] ) ) {
$tags[ $tag ] = array();
}
$tags[ $tag ] = array_merge( $tags[ $tag ], $data_attrs );
}
return $tags;
}, 10, 2 );
Two details from the documentation matter here. First, the filter receives both the tag array and the context string, so you can scope the change to 'post' and leave other contexts alone. Second, attribute names must be lowercase. data-Tone will not be recognized.
If your attributes carry URLs, note that KSES also filters protocols through kses_allowed_protocols, documented at the kses_allowed_protocols filter. A data- attribute holding a tel: or custom-scheme URL will be stripped unless the protocol is allowed.
Option 2: Grant unfiltered_html to the role that needs it
This is the correct fix only if you genuinely trust that role with unfiltered HTML. On a single-site install, unfiltered_html is held by the Administrator and Editor roles by default. On multisite, only Super Admins hold it. The capability is not a formatting convenience. It is the switch that turns KSES off for that user’s saves.
If you grant it, you are accepting that the user can store scripts, event handlers, and any other markup the allowlist would have removed. That is the trade. Do not grant it to a role whose members you would not trust with theme-file access.
Verification
Do not verify by looking at the editor. Verify at the database and at the REST boundary.
First, confirm what is actually stored:
wp db query "SELECT ID, post_author, post_status,
LOCATE('data-tone', post_content) AS has_tone
FROM wp_posts
WHERE post_type = 'post'
ORDER BY post_modified DESC
LIMIT 10;"
A has_tone value of 0 on a post that should have the attribute confirms the strip happened at save time, not at render time.
Second, confirm the capability state for the user who saved it:
wp user get editor@example.com --field=roles
wp cap list editor | grep unfiltered_html
If unfiltered_html is absent, KSES is active for that user’s saves. That is expected.
Third, confirm the allowlist your filter produces. Run this in a context where your filter is loaded, such as a small mu-plugin or a WP-CLI eval:
wp eval '
$tags = wp_kses_allowed_html( "post" );
var_dump( isset( $tags["div"]["data-tone"] ) );
'
If that prints bool(false), your filter is not running in the context you think it is, or the attribute name is cased incorrectly, or the tag you targeted is not the tag in the markup.
Fourth, test the REST path directly, because that is the path the block editor uses:
wp eval '
$content = "<div class=\"callout\" data-tone=\"warning\">x</div>";
echo wp_kses_post( $content );
'
Run this as the user without unfiltered_html and as the user with it. The first user’s output should retain data-tone only if your filter is active. The second user’s output will retain it regardless, because wp_kses_post() called directly does not check capabilities. The capability check happens in the save pipeline, not inside wp_kses_post() itself. That distinction is the source of most confusion about this bug.
What about content saved before the role change
KSES runs on save, not on read. Content stored while the user had unfiltered_html keeps its attributes in the database. If that user later loses the capability, the stored content is not retroactively stripped. The next save by a user without the capability will strip it, because that save passes through KSES.
This is why the bug often appears after a role change, a multisite migration, or a change to who is editing a given post. The content did not change. The capability of the person saving it did.
Why this is not a bug to report upstream
KSES is an allowlist by design. The default post allowlist is deliberately conservative. data-* attributes are not in it because the set of possible attribute names is unbounded, and an allowlist that permits all of them is not an allowlist. The extension point is the wp_kses_allowed_html filter, and it exists precisely so that sites can name the attributes they need.
The failure mode is not that KSES strips your attributes. It is that the strip is silent, capability-dependent, and only visible in the stored content. Once you know the capability gate is the variable, the fix is a filter or a capability grant, and the verification is a database query.
FAQ
Does wp_kses_post() check capabilities?
No. It filters unconditionally against the post allowlist. The capability check happens in the save pipeline, which decides whether to call it. Calling wp_kses_post() directly in a theme or plugin will strip attributes regardless of who is logged in.
Can I allow all data-* attributes at once?
Not through the documented filter. KSES matches attribute names against the allowlist array. There is no wildcard syntax. You can name each attribute you need, or you can grant unfiltered_html and accept the consequences.
Why did the attribute survive in the editor preview but not after save?
The editor preview renders from the in-memory content before the save round-trip. The save round-trip is where KSES runs. The preview is not evidence that the attribute will persist.
Does this affect custom blocks?
If the custom block stores its data in block attributes (the JSON in the block comment delimiter), those are not subject to KSES the same way. If it stores data in HTML attributes inside the block’s rendered content, those are subject to KSES on save by users without unfiltered_html.
Where is unfiltered_html defined for multisite?
On multisite, unfiltered_html is restricted to super admins by default. The current_user_can_for_site() function, documented at the developer reference, is the site-scoped capability check. If you are debugging a multisite install, check the capability in the context of the site where the save happened, not the network context.





