Restrict Which Blocks Each User Role Can Use in WordPress (Per-Role, Per-Post-Type)
The WordPress Block Editor ships with ~100 core blocks, and every plugin you install adds more — GenerateBlocks adds 8, Kadence adds 30+, Ultimate Addons adds 90. By default, every user with edit_posts capability sees all of them in the inserter. That’s the wrong permission model for any site with more than one person editing. This snippet locks down blocks per role and per post type, in about 60 lines of PHP.
I shipped this after an intern on a client site used the Stackable “Animated Counter” block inside a legal-disclaimer post. Not their fault — the block was there, it looked useful, they inserted it. The block loaded 120 KB of animation JS on a page that needed zero animation. Deleting it was easy; preventing it from happening again required a real permission model. The approach below is what I settled on after trying three different plugins (none worth running) and then writing exactly the thing I wanted. It’s used on gauravtiwari.org, gatilab.com, and every client site where more than one person touches the editor. Takes 15 minutes to configure, survives plugin updates, zero admin-JS overhead.
What this snippet does
- Allowlist by role — each WordPress role (administrator, editor, author, contributor, subscriber, plus any custom role you’ve defined) gets a specific set of blocks available in the inserter
- Allowlist by post type — different blocks for posts vs pages vs custom post types. Your FAQ block only appears on posts, your product-box block only on products
- Inherited allowlists — higher roles automatically inherit the allowlists of lower roles (admin sees everything, editor sees everything except admin-only, etc.)
- Works with every block plugin — GenerateBlocks, Kadence, Stackable, Ultimate Addons, ACF Blocks, MD Blocks — the filter applies to every block regardless of origin
- Hides blocks from the inserter AND prevents paste — users can’t drop a restricted block in via block comment paste either; the server-side validation rejects it on save
- Zero admin-JS overhead — everything is server-rendered PHP filters, no React, no admin enqueues, instant effect
- Survives plugin updates — the allowlist is referenced by block name, not by block metadata, so plugin upgrades don’t reset your rules
- Debug-friendly — ships with a
define('GT_BLOCKS_DEBUG', true)flag that prints the resolved allowlist to the browser console for each role/post combination
Install and configure
Drop into wp-content/mu-plugins/gt-block-allowlist.php. Edit the configuration arrays at the top of the file — list the block names each role should have access to, plus per-post-type restrictions. Save. No cache to flush; the allowlist is re-computed on every editor load. To discover block names, open any post in the Block Editor as an admin, open browser DevTools, run wp.blocks.getBlockTypes().map(b => b.name) in the console — paste the output into your config.
<?php
/**
* Plugin Name: GT Block Allowlist
* Description: Per-role, per-post-type allowlist for the Block Editor inserter.
*/
defined( 'ABSPATH' ) || exit;
/* ============================================================
* Configuration
* ============================================================
* block_names are the string identifiers used by WordPress block registration.
* core blocks are prefixed 'core/', plugin blocks use their plugin namespace.
* ============================================================ */
function gt_blocks_allowlist_for_role( $role ) {
/* Shared base — every role sees these blocks unconditionally */
$base = [
'core/paragraph', 'core/heading', 'core/list', 'core/list-item',
'core/image', 'core/quote', 'core/separator', 'core/spacer',
];
switch ( $role ) {
case 'contributor':
return array_merge( $base, [
'core/code', 'core/table',
] );
case 'author':
return array_merge( $base, [
'core/code', 'core/table', 'core/gallery', 'core/embed',
'core/html', 'core/pullquote',
'acf/callout', 'acf/accordion', /* ACF blocks editors on gauravtiwari.org need */
] );
case 'editor':
/* Editor = author + SEO-team blocks */
return array_merge( gt_blocks_allowlist_for_role( 'author' ), [
'acf/product-box', 'acf/opinion-box', 'acf/toc',
'acf/compare-table',
'generateblocks/element', 'generateblocks/text', 'generateblocks/media',
'marketers-delight/page-block', 'marketers-delight/inline-page-block',
] );
case 'administrator':
return null; /* null = no restriction, all blocks available */
default:
return $base;
}
}
function gt_blocks_allowlist_for_post_type( $post_type ) {
/* Return an array of extra allowed blocks on this post type,
* or null to apply no post-type restriction. */
switch ( $post_type ) {
case 'snippet':
/* Only allow a curated set on the snippet CPT — consistency */
return [
'core/paragraph', 'core/heading', 'core/list', 'core/list-item',
'core/code', 'core/separator',
'acf/accordion', 'acf/toc',
'marketers-delight/inline-page-block',
];
case 'fluent-products':
return null; /* no restriction on product pages — authoring needs flexibility */
default:
return null;
}
}
/* ============================================================
* Apply the allowlist in the editor
* ============================================================ */
add_filter( 'allowed_block_types_all', function ( $allowed, $editor_context ) {
if ( ! current_user_can( 'edit_posts' ) ) return $allowed;
$user = wp_get_current_user();
$roles = (array) $user->roles;
$role = $roles[0] ?? 'subscriber'; /* primary role */
$role_list = gt_blocks_allowlist_for_role( $role );
if ( $role_list === null ) return $allowed; /* no restriction for this role */
/* Intersect with per-post-type list if present */
$post_type = method_exists( $editor_context, 'get_post' )
? get_post_type( $editor_context->get_post() )
: ( $editor_context->post_type ?? 'post' );
$pt_list = gt_blocks_allowlist_for_post_type( $post_type );
if ( $pt_list !== null ) {
$role_list = array_values( array_intersect( $role_list, $pt_list ) );
}
/* Optional debug: surface the computed list to the browser console */
if ( defined( 'GT_BLOCKS_DEBUG' ) && GT_BLOCKS_DEBUG ) {
add_action( 'admin_footer', function () use ( $role, $post_type, $role_list ) {
printf( '<script>console.log(%s, %s, %s);</script>',
wp_json_encode( "role={$role}" ),
wp_json_encode( "post_type={$post_type}" ),
wp_json_encode( $role_list ) );
} );
}
return $role_list;
}, 10, 2 );
/* ============================================================
* Server-side paste protection — reject disallowed blocks on save
* ============================================================ */
add_filter( 'wp_insert_post_data', function ( $data, $postarr ) {
if ( empty( $data['post_content'] ) || ! has_blocks( $data['post_content'] ) ) return $data;
if ( current_user_can( 'manage_options' ) ) return $data; /* admins bypass */
$user = wp_get_current_user();
$role = ( $user->roles[0] ?? 'subscriber' );
$allow = gt_blocks_allowlist_for_role( $role );
if ( $allow === null ) return $data;
$blocks = parse_blocks( $data['post_content'] );
foreach ( $blocks as $b ) {
if ( ! empty( $b['blockName'] ) && ! in_array( $b['blockName'], $allow, true ) ) {
wp_die( sprintf(
esc_html__( 'Your role (%1$s) cannot use the %2$s block.' ),
esc_html( $role ), esc_html( $b['blockName'] )
), '', [ 'response' => 403, 'back_link' => true ] );
}
}
return $data;
}, 10, 2 );How it works end-to-end
Two filters, two concerns. allowed_block_types_all controls what the Block Editor shows in the inserter — that’s the visible UX. It fires when the editor loads, receives the list of all registered blocks, and we return a filtered subset based on the current user’s role and the post being edited. Returning null means no restriction (admin case). Returning an array means the editor hides every block not in that list — they don’t appear in search, don’t appear in the patterns panel, can’t be inserted. But the editor is a client-side tool, and a determined user can bypass it by pasting block comment markup directly into the raw content or hitting the REST API. That’s why we add the second filter. wp_insert_post_data runs server-side on every save. We parse the blocks using WordPress’s own parse_blocks() function, walk the tree, and reject the save if any block name is outside the allowlist. Administrators are exempted via current_user_can('manage_options') so they don’t accidentally lock themselves out. The two together give you a deterministic permission model — whichever path a user takes (UI insertion, REST API, code paste), disallowed blocks can’t reach the database. Debug mode surfaces the computed allowlist as a console.log per-request, which is invaluable when a role reports “block X is missing” and you need to confirm whether the rule is correct or the block was actually restricted.
Download and source
- Full mu-plugin: gist.github.com/wpgaurav — search gt-block-allowlist
- Bundled in the Functionalities plugin
- Discover block names in DevTools console:
wp.blocks.getBlockTypes().map(b => b.name) - WordPress core docs: allowed_block_types_all filter reference
- For a UI-driven alternative if you don’t want code: Block Manager plugin — less flexible but clickable
FAQs
What block names do I use in my config?
Every block has a namespaced identifier — core blocks are core/paragraph, core/heading, etc. Plugin blocks use their plugin’s namespace: generateblocks/element, kadence/rowlayout, acf/accordion, stackable/card. Discover them by running wp.blocks.getBlockTypes().map(b => b.name) in the browser DevTools console while in the Block Editor as an admin. Copy the output into your config.
Does this break reusable patterns / synced patterns?
No. Synced patterns (core/block) are treated as a single block by the editor; if you allow core/block, the entire saved pattern renders regardless of what blocks are inside it. To also restrict what’s allowed inside patterns, you’d need a separate check when saving the pattern itself, which is rarely worth the complexity.
How do I handle block variations (like columns with specific presets)?
Variations are not separate blocks — they’re UI shortcuts on top of an existing block. If you allow core/columns, all variations of it are available. To restrict a specific variation, you’d override its metadata client-side, which is more involved than this snippet covers.
Will this affect Full Site Editing templates?
Yes. The allowed_block_types_all filter applies to the Site Editor too. If you use FSE, allow the navigation / site-logo / post-content blocks for your editor/admin roles so they can build templates. Contributors editing posts don’t need those and can keep the tighter list.
Can I allow a block on one specific page only?
Yes. The filter receives the editor context, so you can check $editor_context->get_post()->ID against a list of specific post IDs. Useful for a “launch page” where you’ve vetted one complex block pattern and want it available only there. Don’t over-engineer this — per-page allowlists get unwieldy fast.
What about block inner-blocks restrictions (a Group block can only contain paragraphs)?
Different feature — handled by the block’s own allowedBlocks attribute at registration time, not by this filter. Core blocks like Columns and Cover support nested allowlists via the block.json schema. For custom restrictions, register a block variation with a fixed allowedBlocks list.
Does this prevent admins from using restricted blocks themselves?
No. Administrators bypass the allowlist (the gt_blocks_allowlist_for_role('administrator') function returns null). Change that to return a specific list if you want to restrict yourself too — useful for forcing your own discipline on gauravtiwari.org-style personal sites.
How do I audit what blocks my users are actually using?
Query the wp_posts table for block patterns. Something like: SELECT COUNT(*), SUBSTRING_INDEX(SUBSTRING_INDEX(post_content, 'wp:', -1), ' ', 1) AS block_name FROM wp_posts WHERE post_status='publish' GROUP BY block_name ORDER BY COUNT(*) DESC LIMIT 20. Rough but useful — shows your most-used blocks. Refine the allowlist based on real usage rather than guesses.
What’s the performance cost?
Zero front-end cost — the filter only fires in the editor context. Admin-side cost is negligible: one array comparison per editor load. Server-side save check is one parse_blocks() call per save, which is already part of WP’s rendering pipeline. Measurable impact: under 5ms on every save, invisible on editor loads.