Exposing ACF in REST API Securely
Secure REST exposure is a data-contract problem first and a field-retrieval problem second.
You will build REST payloads that expose only approved ACF fields, enforce capability boundaries on sensitive endpoints, and verify API contracts from WP-CLI before each release.
Concept Overview
In headless WordPress systems, REST responses often become your source of truth for web apps, mobile clients, and integrations. If you expose raw get_fields() output without curation, you leak implementation details and potentially sensitive values. Secure API design starts with explicit mapping of required keys, typed output, and endpoint-specific permission policy.
ACF data itself is not unsafe, but unchecked serialization is. Fields may contain internal notes, operational flags, or URLs that should not be public. Even harmless overexposure creates schema drift: frontend teams begin depending on keys that were never part of your official contract, and small editor changes become API-breaking events.
WordPress gives you two robust exposure patterns: register_rest_field() to augment existing core endpoints and register_rest_route() for custom namespaced contracts. Both patterns should use deterministic key maps, strict escaping/normalization, and predictable fallback values so clients can rely on stable response shape.
Expose only what clients need, never what storage happens to contain, and prove those boundaries with CLI-driven contract checks.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
| Whitelist-only payload mapping | REST responses contain only approved keys | Security posture improves and schema remains stable |
Route-level permission_callback for sensitive data | Unauthorized users get blocked before callback logic runs | Lower risk of internal data leakage |
Typed fallbacks ('', [], null) in every response | Clients can render without defensive guesswork | Fewer frontend runtime failures |
Versioned namespace (site/v1) with documented keys | API changes are explicit and reviewable | Safer incremental releases |
Raw get_fields() exposed publicly (wrong pattern) | Hidden/internal fields leak and clients couple to accidental keys | Compliance risk and brittle integrations |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
register_rest_field() | `register_rest_field(string | array $object_type, string $attribute, array $args = []): void` | Add controlled ACF-derived fields to core endpoints |
register_rest_route() | register_rest_route(string $namespace, string $route, array $args = [], bool $override = false): bool | Create namespaced protected endpoints | Always define permission_callback |
permission_callback | function (WP_REST_Request $request): bool | Gate access by capability/context | Return false or WP_Error for denied access |
get_field() | `get_field(string $selector, int | string $post_id = false, bool $format_value = true): mixed` | Retrieve specific ACF value for payload assembly |
rest_ensure_response() | rest_ensure_response(mixed $response): WP_REST_Response | Normalize callback return into valid REST response | Useful for typed status and headers |
WP_REST_Request | new WP_REST_Request(string $method = 'GET', string $route = '') | Programmatically test endpoint behavior | Great for CLI contract checks |
rest_do_request() | rest_do_request(WP_REST_Request $request): WP_HTTP_Response | Execute REST request in-process | Enables no-network verification in CI/CLI |
show_in_rest field setting | ACF field group/field setting in admin | Include ACF field data in REST context | Review every exposed field intentionally |
If you depend on ACF Pro field types (such as Repeater or Flexible Content) in REST payload mapping, those field definitions require ACF Pro.
Practical Use Cases
1. Public page hero payload with strict key whitelist
A marketing frontend consumes page hero data from /wp/v2/pages/<id>. The team needs headline, CTA label, and CTA URL, but must not expose unrelated ACF values on that page.
- Register a
hero_payloadfield on thepageendpoint. - Read only required ACF keys from the current page ID.
- Normalize and escape values into predictable output.
- Return a fixed object shape with defaults for empty fields.
- Validate payload keys via in-process REST request from CLI.
<?php
declare(strict_types=1);
add_action('rest_api_init', function (): void {
register_rest_field('page', 'hero_payload', [
'get_callback' => static function (array $object): array {
$postId = (int) ($object['id'] ?? 0);
if ($postId <= 0) {
return [
'headline' => '',
'cta_label' => '',
'cta_url' => '',
];
}
$headline = (string) get_field('service_headline', $postId);
$ctaLabel = (string) get_field('service_cta_label', $postId);
$ctaUrl = (string) get_field('service_cta_url', $postId);
return [
'headline' => wp_strip_all_tags($headline),
'cta_label' => wp_strip_all_tags($ctaLabel),
'cta_url' => esc_url_raw($ctaUrl),
];
},
'schema' => [
'description' => 'Public hero contract for headless pages.',
'type' => 'object',
'context' => ['view'],
'properties' => [
'headline' => ['type' => 'string'],
'cta_label' => ['type' => 'string'],
'cta_url' => ['type' => 'string'],
],
],
]);
});
wp eval '$id=1204; $r=new WP_REST_Request("GET","/wp/v2/pages/".$id); $res=rest_do_request($r); $data=$res->get_data(); print_r(array_keys($data["hero_payload"]));'
wp eval '$id=1204; $r=new WP_REST_Request("GET","/wp/v2/pages/".$id); $res=rest_do_request($r); print_r($res->get_data()["hero_payload"]);'
Array
(
[0] => headline
[1] => cta_label
[2] => cta_url
)
Array
(
[headline] => Laser Skin Renewal
[cta_label] => Book Consultation
[cta_url] => https://example.com/book-consultation
)
A stable key list is part of your API contract. Adding or removing keys should go through change review, not ad-hoc template edits.
2. Protected compliance endpoint with capability checks and typed errors
A legal operations dashboard needs ACF-backed compliance data per case-study post. This data includes internal status and reviewer notes and must never be publicly readable.
- Register a namespaced route under
internal/v1. - Enforce capability with
permission_callback. - Return typed
WP_Errorwhen post does not exist. - Map only allowed compliance fields into output.
- Validate route behavior for allowed and denied access paths.
<?php
declare(strict_types=1);
add_action('rest_api_init', function (): void {
register_rest_route('internal/v1', '/compliance/(?P<id>\d+)', [
'methods' => 'GET',
'permission_callback' => static function (WP_REST_Request $request): bool {
return current_user_can('edit_others_posts');
},
'callback' => static function (WP_REST_Request $request) {
$postId = (int) $request->get_param('id');
$post = get_post($postId);
if (!$post instanceof WP_Post || $post->post_type !== 'case_study') {
return new WP_Error(
'compliance_post_not_found',
'Case study not found.',
['status' => 404]
);
}
$payload = [
'post_id' => $postId,
'compliance_status' => (string) get_field('case_compliance_status', $postId),
'reviewer' => (string) get_field('case_compliance_reviewer', $postId),
'reviewed_at' => (string) get_field('case_compliance_reviewed_at', $postId),
'internal_note' => (string) get_field('case_internal_audit_note', $postId),
];
return rest_ensure_response($payload);
},
]);
});
wp eval 'global $wp_rest_server; do_action("rest_api_init"); $routes=$wp_rest_server->get_routes(); echo array_key_exists("/internal/v1/compliance/(?P<id>\\d+)",$routes) ? "route-ok" : "route-missing"; echo PHP_EOL;'
wp eval '$u=wp_create_user("rest-auditor","StrongPass!2026","rest-auditor@example.com"); $req=new WP_REST_Request("GET","/internal/v1/compliance/9999"); wp_set_current_user($u); $res=rest_do_request($req); echo $res->get_status() . PHP_EOL;'
route-ok
403
Never use __return_true for protected routes during development and forget to remove it. That single shortcut is a production data leak waiting to happen.
3. Edge case: accidental raw field dump leaks internal metadata
An implementation returns get_fields($postId) directly in a REST callback. It works quickly but leaks internal fields, private notes, and experiment flags that were never intended for public clients.
Fragile Pattern (Bad)
<?php
declare(strict_types=1);
add_action('rest_api_init', function (): void {
register_rest_route('site/v1', '/page/(?P<id>\d+)/acf', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => static function (WP_REST_Request $request): array {
$postId = (int) $request->get_param('id');
return (array) get_fields($postId);
},
]);
});
Robust Pattern (Good)
<?php
declare(strict_types=1);
add_action('rest_api_init', function (): void {
register_rest_route('site/v1', '/page/(?P<id>\d+)/public', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => static function (WP_REST_Request $request) {
$postId = (int) $request->get_param('id');
$post = get_post($postId);
if (!$post instanceof WP_Post || $post->post_type !== 'page') {
return new WP_Error('page_not_found', 'Page not found.', ['status' => 404]);
}
$payload = [
'id' => $postId,
'title' => wp_strip_all_tags(get_the_title($postId)),
'hero' => [
'headline' => wp_strip_all_tags((string) get_field('service_headline', $postId)),
'subheadline' => wp_strip_all_tags((string) get_field('service_subheadline', $postId)),
'cta_url' => esc_url_raw((string) get_field('service_cta_url', $postId)),
],
'seo' => [
'meta_title' => wp_strip_all_tags((string) get_field('seo_meta_title', $postId)),
'meta_description' => wp_strip_all_tags((string) get_field('seo_meta_description', $postId)),
],
];
return rest_ensure_response($payload);
},
]);
});
wp eval '$id=1204; $req=new WP_REST_Request("GET","/site/v1/page/".$id."/acf"); $res=rest_do_request($req); $data=$res->get_data(); echo isset($data["case_internal_audit_note"]) ? "leak-detected" : "no-leak"; echo PHP_EOL;'
wp eval '$id=1204; $req=new WP_REST_Request("GET","/site/v1/page/".$id."/public"); $res=rest_do_request($req); $data=$res->get_data(); echo isset($data["case_internal_audit_note"]) ? "leak-detected" : "no-leak"; echo PHP_EOL;'
leak-detected
no-leak
A callback that returns everything is not a shortcut, it is an unbounded contract. Public APIs need strict key-level control.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
Returning raw get_fields() in public route | Contract not designed, storage dumped directly | Sensitive keys leak and clients depend on accidental fields | Build explicit payload arrays with get_field() per key |
Missing permission_callback on internal routes | Assumes endpoint is private by URL only | Unauthorized users can read protected payloads | Require capability checks and return 403 when denied |
| Inconsistent null/empty handling per key | No payload schema discipline | Frontend guards become complex and error-prone | Return fixed key set with deterministic defaults |
| Using field names that change during refactor | No API versioning strategy | Client breakage after editor/model updates | Version namespace and map old/new fields intentionally |
| Exposing formatted HTML-rich values without sanitization | Trusting editor content for all clients | Injection or rendering issues in consumers | Normalize with wp_strip_all_tags() or strict escaping |
| No CLI contract tests before release | Manual browser-only QA | Regressions ship undetected | Add rest_do_request checks in CI or release scripts |
Deep Dive: Why Raw ACF Dumps Seem Safe Until They Are Not
Teams often test with a few harmless fields and conclude that raw dumps are convenient and stable. The hidden risk appears later when new internal fields are added for editorial workflow, legal review, or experimentation. Because the route is already public, those fields become instantly exposed without any code change in the endpoint itself. This is hard to catch with manual QA because nobody expects unrelated model edits to alter API exposure. A contract-driven mapper prevents this class of issue by requiring deliberate key additions.
wp eval '$id=1204; $req=new WP_REST_Request("GET","/site/v1/page/".$id."/acf"); $res=rest_do_request($req); echo implode(",", array_keys((array) $res->get_data())) . PHP_EOL;'
Best Practices
- Define one approved key list per endpoint and verify with
wp eval 'print_r(array_keys(rest_do_request(new WP_REST_Request("GET","/wp/v2/pages/1204"))->get_data()["hero_payload"]));'. - Enforce permissions on every non-public route:
wp eval 'echo current_user_can("edit_others_posts") ? "yes" : "no"; echo PHP_EOL;'. - Return typed defaults for all optional fields so clients always receive stable shape.
- Keep API contract code in dedicated
inc/rest/files and code review each key addition. - Version route namespaces (
site/v1,site/v2) when changing payload semantics. - Sanitize output values in callback mapping (
wp_strip_all_tags,esc_url_raw) even when source fields are trusted. - Add CLI smoke checks for route registration and representative response keys before deploy.
Hands-On Practice
Exercise 1: Register and verify a whitelisted public field
Create wp-content/themes/clinic-headless/inc/rest/public-hero-payload.php using Use Case 1, then run:
wp eval '$id=1204; $r=new WP_REST_Request("GET","/wp/v2/pages/".$id); $res=rest_do_request($r); print_r($res->get_data()["hero_payload"]);'
After completing this exercise, output should include only:
[headline]
[cta_label]
[cta_url]
Exercise 2: Add and verify protected route registration
Create wp-content/plugins/clinic-api/includes/routes/internal-compliance.php using Use Case 2, then run:
wp eval 'global $wp_rest_server; do_action("rest_api_init"); $routes=$wp_rest_server->get_routes(); echo isset($routes["/internal/v1/compliance/(?P<id>\\d+)"]) ? "present" : "missing"; echo PHP_EOL;'
After completing this exercise, output should be:
present
Exercise 3: Confirm unauthorized access is denied
Run:
wp eval '$u=wp_create_user("api-visitor","TempPass!2026","api-visitor@example.com"); wp_set_current_user($u); $req=new WP_REST_Request("GET","/internal/v1/compliance/1204"); $res=rest_do_request($req); echo $res->get_status() . PHP_EOL;'
After completing this exercise, output should be:
403
Exercise 4: Detect accidental key leakage
Run:
wp eval '$id=1204; $req=new WP_REST_Request("GET","/site/v1/page/".$id."/public"); $res=rest_do_request($req); $keys=array_keys((array) $res->get_data()); echo in_array("case_internal_audit_note",$keys,true) ? "leak" : "clean"; echo PHP_EOL;'
After completing this exercise, output should be:
clean
Exercise 5: Build a release contract gate command
Run:
wp eval '$id=1204; $res=rest_do_request(new WP_REST_Request("GET","/wp/v2/pages/".$id)); $hero=$res->get_data()["hero_payload"]; $required=["headline","cta_label","cta_url"]; echo count(array_diff($required,array_keys($hero)))===0 ? "contract-ok" : "contract-fail"; echo PHP_EOL;'
After completing this exercise, output should be:
contract-ok
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval 'global $wp_rest_server; do_action("rest_api_init"); $routes=$wp_rest_server->get_routes(); echo isset($routes["/internal/v1/compliance/(?P<id>\\d+)"]) ? "present" : "missing"; echo PHP_EOL;' | Confirm sensitive route is registered | present |
wp eval '$id=1204; $res=rest_do_request(new WP_REST_Request("GET","/wp/v2/pages/".$id)); print_r($res->get_data()["hero_payload"]);' | Read whitelisted public payload | Array ( [headline] => Laser Skin Renewal [cta_label] => Book Consultation [cta_url] => https://example.com/book-consultation ) |
wp eval '$id=1204; $res=rest_do_request(new WP_REST_Request("GET","/site/v1/page/".$id."/public")); echo $res->get_status() . PHP_EOL;' | Verify public custom endpoint responds | 200 |
wp eval '$user=wp_create_user("rest-tester","StrongPass!2026","rest-tester@example.com"); wp_set_current_user($user); $res=rest_do_request(new WP_REST_Request("GET","/internal/v1/compliance/1204")); echo $res->get_status() . PHP_EOL;' | Ensure unauthorized user is blocked | 403 |
wp eval '$id=1204; $res=rest_do_request(new WP_REST_Request("GET","/site/v1/page/".$id."/public")); echo implode(",", array_keys((array) $res->get_data())) . PHP_EOL;' | Inspect top-level contract keys | id,title,hero,seo |
wp eval '$id=1204; $res=rest_do_request(new WP_REST_Request("GET","/site/v1/page/".$id."/public")); $hero=$res->get_data()["hero"]; echo array_key_exists("cta_url",$hero) ? "cta-present" : "cta-missing"; echo PHP_EOL;' | Validate required nested key | cta-present |
| `wp plugin list --status=active | rg advanced-custom-fields-pro` | Confirm ACF Pro is active in environment |
ls wp-content/themes/clinic-headless/inc/rest/ | Verify REST contract files are present | public-hero-payload.php contracted-page-payload.php |
What's Next
- Continue to Caching and Query Optimization for Field-Heavy Templates.
- Return to Module 8 Overview to map this endpoint work into the full enterprise pattern set.
- Related lesson: Field Retrieval APIs and Context-Aware Data Access.
- Related lesson: validate_value, Sanitization, and Publish Guards.
Revisit this lesson whenever a new frontend consumer asks for "one more field" in a public endpoint, because that request is the exact moment contract and permission discipline matters most.