Skip to main content

Securing Apex REST endpoints - who can call a @RestResource class, and what it should check

Securing Apex REST endpoints starts with a fact that is easy to forget while writing one: a class annotated with @RestResource is a public HTTP API. It lives at /services/apexrest/<urlMapping> on your org's domain, anything that holds a valid session or OAuth token for a user with access to the class can call it with any body it likes, and if the class is granted to a site's guest user, the caller needs no login at all.

This post walks through who can reach an Apex REST endpoint, the four checks every handler needs, and how to keep an inventory so a new endpoint never ships unnoticed.

Who can call an Apex REST endpoint?

Salesforce documents the mechanics in Exposing Apex Classes as REST Web Services. The class must be global, each handler is a global static method with @HttpGet, @HttpPost, @HttpPut, @HttpPatch or @HttpDelete, and callers authenticate with OAuth 2.0 or a session ID.

That gives three audiences:

  • Internal users. Any user whose profile or permission set grants access to the Apex class. That includes users who never see a UI built on it.
  • Integration and connected app users. An external system that obtained a token through a connected app (or an External Client App) acts as the user behind that token. Whatever that user can do, the integration can do, and a leaked refresh token hands that reach to whoever holds it.
  • Guest users on a site. If the class is added to the site's guest profile, it is reachable at the site URL followed by /services/apexrest/... with no authentication. Salesforce's own developer blog on public REST services warns that the endpoint then has no authentication and that object and field permissions must be set with care.

The third case is the one that turns a coding mistake into a data exposure. A forgotten class grant on a guest profile is enough.

Check 1: sharing

The handler class is the entry point of the transaction, so its sharing mode decides which records every query sees. On API 66.0 and earlier, a class with no sharing keyword used as an Apex REST entry point runs in system context. On API 67.0, a class with no declaration defaults to with sharing.

Declare it explicitly either way:

@RestResource(urlMapping='/orders/*')
global with sharing class OrderApi {
    @HttpGet
    global static Order__c getOrder() {
        String orderId = RestContext.request.requestURI.substringAfterLast('/');
        return [SELECT Id, Status__c, Total__c FROM Order__c
                WHERE Id = :orderId WITH USER_MODE];
    }
}

If a handler genuinely needs to bypass sharing, move that one query into a small without sharing helper that takes no caller-chosen record id and returns the minimum. Our post on sharing keywords covers that pattern.

Check 2: CRUD and FLS

Sharing decides which records. It does not decide which objects and fields. Before API 67.0, database operations in Apex run in system mode, so a with sharing REST handler can still return fields the caller's profile hides, or write fields the caller cannot edit.

Use WITH USER_MODE on queries, as user or AccessLevel.USER_MODE on writes, or Security.stripInaccessible when the response should degrade rather than fail. The trade-offs are in Apex CRUD and FLS enforcement.

Check 3: input validation

Everything in RestContext.request is attacker-controlled: the URI, the parameters, the headers and the body. Three patterns cause most of the damage.

Deserializing straight into a record. This is mass assignment: the caller picks which fields are written, including OwnerId or a status field the UI never exposes.

// Vulnerable
@HttpPatch
global static void updateCase() {
    Case c = (Case) JSON.deserialize(
        RestContext.request.requestBody.toString(), Case.class);
    update c;
}
// Fixed: a request class names the writable fields, and the write runs as the user
global class CaseUpdate { public Id caseId; public String subject; }

@HttpPatch
global static void updateCase() {
    CaseUpdate req = (CaseUpdate) JSON.deserialize(
        RestContext.request.requestBody.toString(), CaseUpdate.class);
    Case c = new Case(Id = req.caseId, Subject = req.subject);
    update as user c;
}

Caller-supplied ids with no ownership check. If the endpoint reads or writes a record by an id from the URL, either sharing must stop the caller from reaching other people's records, or the query must filter by ownership. Otherwise it is an insecure direct object reference.

Dynamic SOQL. A parameter concatenated into Database.query is SOQL injection. Use bind variables; see Apex SOQL injection.

Also validate types and lengths before use, and return errors that do not echo internal details or reveal whether a record exists.

Check 4: authorization beyond permissions

Profiles and permission sets answer "may this user call this class at all". They do not answer "may this user perform this action on this record". For anything destructive (a delete, a refund, a role or permission change), check a custom permission with FeatureManagement.checkPermission or an explicit business rule inside the handler. Granting class access to an integration user should not silently grant every operation the class contains.

How exposed is each endpoint?

The risk of an endpoint depends on who can reach it, not only on what the code does. A useful way to rank them:

Reachable by What a mistake means
Site guest user Anyone on the internet can trigger it
Integration or connected app user Anyone holding that token can trigger it, often with broad access
Authenticated internal or community users Any user with the class can call it outside the UI
No profile or permission set grants the class Not reachable until someone grants it

Review guest-reachable endpoints first, then classes granted to integration users with wide permission sets. Keep the list current: endpoints accumulate, and old ones outlive the integration that needed them. Remove the class grant, or delete the class, when an integration is retired.

Volume also matters. Salesforce applies org-wide API allocations, but those protect the platform, not your data. An endpoint that confirms whether an email address or case number exists can be enumerated at whatever speed the caller likes, so design responses so that repeated calls reveal nothing useful.

Inventorying Apex REST with Vulkro

Vulkro for Salesforce lists every @RestResource class, HTTP verb method and webservice method as SF-ATTACK-SURFACE-005, with CRUD, FLS, sharing and an auth check as the things to confirm. The entrypoints command prints the full inventory, including the sharing keyword, the resolved API version and whether a guest profile in the source grants the class:

vulkro-sf entrypoints ./force-app
vulkro-sf entrypoints ./force-app --guest-only
vulkro-sf exposure ./force-app

exposure reports what an unauthenticated visitor can reach, per site, with the proof behind each finding. The scan itself also flags Apex REST methods that change state with no authorization gate, and mass assignment through JSON.deserialize (mass-assignment-json-deserialize-sobject).

FAQ

Can guest users call Apex REST services?

Yes, when the Apex class is granted to a site's guest user profile. The endpoint is then reachable at the site URL followed by /services/apexrest/ with no login, so the class must enforce sharing, CRUD and FLS itself.

Do Apex REST services run in system mode?

On API 66.0 and earlier, database operations run in system mode by default, and a class with no sharing keyword can run without sharing as the entry point. On API 67.0, user mode and with sharing are the defaults. Declare both explicitly.

How do I secure an Apex REST endpoint?

Declare with sharing, run queries and DML in user mode, deserialize into request classes rather than sObjects, bind every parameter, check ownership on caller-supplied ids, and grant the class only to the profiles and permission sets that need it.

Start a 14-day trial from /start/ to inventory your endpoints. Related: Experience Cloud guest user security checklist.