Skip to main content

Exposing ACF in REST API Securely

Secure REST exposure is a data-contract problem first and a field-retrieval problem second.

Learning Focus

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.

Core Idea

Expose only what clients need, never what storage happens to contain, and prove those boundaries with CLI-driven contract checks.

Why It Matters

ApproachWhat HappensImpact in Production
Whitelist-only payload mappingREST responses contain only approved keysSecurity posture improves and schema remains stable
Route-level permission_callback for sensitive dataUnauthorized users get blocked before callback logic runsLower risk of internal data leakage
Typed fallbacks ('', [], null) in every responseClients can render without defensive guessworkFewer frontend runtime failures
Versioned namespace (site/v1) with documented keysAPI changes are explicit and reviewableSafer incremental releases
Raw get_fields() exposed publicly (wrong pattern)Hidden/internal fields leak and clients couple to accidental keysCompliance risk and brittle integrations

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
register_rest_field()`register_rest_field(stringarray $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): boolCreate namespaced protected endpointsAlways define permission_callback
permission_callbackfunction (WP_REST_Request $request): boolGate access by capability/contextReturn false or WP_Error for denied access
get_field()`get_field(string $selector, intstring $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_ResponseNormalize callback return into valid REST responseUseful for typed status and headers
WP_REST_Requestnew WP_REST_Request(string $method = 'GET', string $route = '')Programmatically test endpoint behaviorGreat for CLI contract checks
rest_do_request()rest_do_request(WP_REST_Request $request): WP_HTTP_ResponseExecute REST request in-processEnables no-network verification in CI/CLI
show_in_rest field settingACF field group/field setting in adminInclude ACF field data in REST contextReview every exposed field intentionally
ACF Pro Required

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.

  1. Register a hero_payload field on the page endpoint.
  2. Read only required ACF keys from the current page ID.
  3. Normalize and escape values into predictable output.
  4. Return a fixed object shape with defaults for empty fields.
  5. Validate payload keys via in-process REST request from CLI.
wp-content/themes/clinic-headless/inc/rest/public-hero-payload.php
<?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'],
],
],
]);
});
terminal: command
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"]);'
terminal: output
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
)
note

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.

  1. Register a namespaced route under internal/v1.
  2. Enforce capability with permission_callback.
  3. Return typed WP_Error when post does not exist.
  4. Map only allowed compliance fields into output.
  5. Validate route behavior for allowed and denied access paths.
wp-content/plugins/clinic-api/includes/routes/internal-compliance.php
<?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);
},
]);
});
terminal: command
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;'
terminal: output
route-ok
403
warning

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)

wp-content/themes/clinic-headless/inc/rest/raw-field-dump-fragile.php
<?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)

wp-content/themes/clinic-headless/inc/rest/contracted-page-payload.php
<?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);
},
]);
});
terminal: command
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;'
terminal: output
leak-detected
no-leak
warning

A callback that returns everything is not a shortcut, it is an unbounded contract. Public APIs need strict key-level control.

Common Mistakes

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Returning raw get_fields() in public routeContract not designed, storage dumped directlySensitive keys leak and clients depend on accidental fieldsBuild explicit payload arrays with get_field() per key
Missing permission_callback on internal routesAssumes endpoint is private by URL onlyUnauthorized users can read protected payloadsRequire capability checks and return 403 when denied
Inconsistent null/empty handling per keyNo payload schema disciplineFrontend guards become complex and error-proneReturn fixed key set with deterministic defaults
Using field names that change during refactorNo API versioning strategyClient breakage after editor/model updatesVersion namespace and map old/new fields intentionally
Exposing formatted HTML-rich values without sanitizationTrusting editor content for all clientsInjection or rendering issues in consumersNormalize with wp_strip_all_tags() or strict escaping
No CLI contract tests before releaseManual browser-only QARegressions ship undetectedAdd 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

  1. 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"]));'.
  2. Enforce permissions on every non-public route: wp eval 'echo current_user_can("edit_others_posts") ? "yes" : "no"; echo PHP_EOL;'.
  3. Return typed defaults for all optional fields so clients always receive stable shape.
  4. Keep API contract code in dedicated inc/rest/ files and code review each key addition.
  5. Version route namespaces (site/v1, site/v2) when changing payload semantics.
  6. Sanitize output values in callback mapping (wp_strip_all_tags, esc_url_raw) even when source fields are trusted.
  7. 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

CommandPurposeReal 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 registeredpresent
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 payloadArray ( [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 responds200
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 blocked403
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 keysid,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 keycta-present
`wp plugin list --status=activerg advanced-custom-fields-pro`Confirm ACF Pro is active in environment
ls wp-content/themes/clinic-headless/inc/rest/Verify REST contract files are presentpublic-hero-payload.php contracted-page-payload.php

What's Next

tip

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.