← Back to list

Integrating Coveo Search with Adobe Experience Manager (AEM): A Deep Dive

Coveo is an AI powered search and relevance platform that pairs naturally with AEM. Rather than relying on AEM’s built-in query builder for…

Francisco Murillo Fallas · 2026-06-07 19:53 · 0 claps · 12.7 min read
#javascript #aem #javascript-frameworks #coveo #backend
Open on Medium ↗
Wiki topics: AI · AI · General 🌐 · Web Development

Integrating Coveo Search with Adobe Experience Manager (AEM): A Deep Dive

Coveo is an AI powered search and relevance platform that pairs naturally with AEM. Rather than relying on AEM’s built-in query builder for full text search, teams use Coveo to deliver faceted, personalized, and entitlement aware search experiences. This post walks through all the moving parts of a production grade AEM + Coveo integration: OSGi services, token generation, search payloads, sitemap metadata, and the Coveo JSUI frontend layer.

Architecture Overview

A typical AEM + Coveo setup involves two integration modes that often coexist:

  1. Direct Coveo REST API integration: AEM backend services call Coveo’s /rest/search/v2 endpoint directly, using a server-side API key. This is ideal for server-rendered pages and batch data lookups (e.g., scheduled jobs that pull product catalog data from Coveo).

  2. Coveo JSUI (JavaScript UI) integration: The front end initializes Coveo’s JavaScript framework using a short lived search token generated by AEM on every page load. This token is user specific and carries entitlement groups and filter expressions, enabling content gating without exposing your API key to the browser.

Both modes share the same OSGi configuration and token infrastructure.

Part 1: OSGi Configuration

Global Search Settings

Coveo’s core connectivity parameters (endpoint URL, organization ID, API key) live in AEM’s JCR under an /apps/settings/wcm/properties style node. A CoveoSearchGlobalService reads these at activation time:

@Reference
CoveoSearchGlobalService coveoSearchGlobalService;

@Activate
protected void activate() {
    coveoSearchEndpoint = coveoSearchGlobalService.getSearchEndpointUrl();
    coveoOrganizationId = coveoSearchGlobalService.getOrganizationId();
    // coveoApiKey is read separately from a secured JCR node
}

Storing credentials in JCR (encrypted) rather than OSGi config values keeps them out of source control and allows environment-specific overrides without a code deployment.

API Gateway Service Configuration

When Coveo is fronted by an API gateway (e.g., for centralized rate limiting or audit logging), a separate OSGi config provides the gateway credentials:

@ObjectClassDefinition(
    name = "Search Gateway Service Configuration",
    description = "OAuth2 credentials and endpoints for the API gateway layer"
)
public @interface SearchGatewayServiceConfig {

    @AttributeDefinition(name = "Access Token URL", type = AttributeType.STRING)
    String accessTokenUrl() default "https://your-gateway.example.com/v1/auth/token";

    @AttributeDefinition(name = "Client ID", type = AttributeType.STRING)
    String clientId() default "";   // stored encrypted in production

    @AttributeDefinition(name = "Client Secret", type = AttributeType.STRING)
    String clientSecret() default ""; // stored encrypted in production

    @AttributeDefinition(name = "Cache Auth Token", type = AttributeType.BOOLEAN)
    boolean useCache4AuthenticationToken() default true;

    @AttributeDefinition(name = "Cache Search Token in Session", type = AttributeType.BOOLEAN)
    boolean useSession4SearchToken() default true;

    @AttributeDefinition(name = "Coveo Get-Content URL", type = AttributeType.STRING)
    String coveoGetContentUrl() default "https://your-gateway.example.com/csearch/v1/get-contents";
}

Key points:

1- useCache4AuthenticationToken: stores the OAuth2 bearer token in a JCS cache region to avoid redundant token calls on every request.

2- useSession4SearchToken: stores the user’s Coveo search token in the HTTP session so it is only regenerated when it expires (typically 1 hour).

Search Token Service Configuration

A second config governs the token payload itself:

@ObjectClassDefinition(
    name = "Search Token Service",
    description = "Controls entitlement groups injected into every Coveo search token"
)
public @interface SearchTokenServiceConfig {

    @AttributeDefinition(
        name = "Environment-specific entitlement groups",
        description = "Groups added to every token from this AEM environment (e.g. env:staging)"
    )
    String[] environmentSpecificEntitlements() default {"env:prod"};

    @AttributeDefinition(
        name = "Supported Role Names",
        description = "Role names whose presence in the user profile adds a corresponding group to the token"
    )
    String[] supportedRoles() default {};
}

Part 2: Authentication and Token Flow

OAuth2 Auth Token (API Gateway Access)

If Coveo is called through an API gateway, you first need a bearer token from that gateway using the client credentials grant:

@Component(immediate = true, service = {SearchGatewayService.class})
@Designate(ocd = SearchGatewayServiceConfig.class)
public class SearchGatewayServiceImpl implements SearchGatewayService {

    private static final String TOKEN_CACHE_REGION = "gatewaytoken";
    private static final String TOKEN_CACHE_KEY    = "gateway_auth_token";

    private String authorizationHeader;
    private SearchGatewayServiceConfig config;

    @Activate @Modified
    public void activate(SearchGatewayServiceConfig config) {
        // Build "Basic Base64(clientId:clientSecret)" header
        String clientId     = decrypt(config.clientId());
        String clientSecret = decrypt(config.clientSecret());
        this.authorizationHeader = createBasicAuthHeader(clientId, clientSecret);
        this.config = config;
    }

    @Override
    public AuthTokenBean getAuthorizationToken(boolean forceRefresh) {
        if (!forceRefresh && config.useCache4AuthenticationToken()) {
            AuthTokenBean cached = JcsCacheUtil.getInstance()
                .get(TOKEN_CACHE_KEY, TOKEN_CACHE_REGION);
            if (cached != null) return cached;
        }

        RestRequest req = new RestRequest();
        req.setUrl(config.accessTokenUrl());
        req.setHeader("Content-Type", "application/x-www-form-urlencoded");
        req.setHeader("Authorization", authorizationHeader);
        req.setParam("grant_type", "client_credentials");

        RestResponse resp = new RestHttpClientImpl().executePost(req);
        AuthTokenBean token = new Gson().fromJson(
            resp.getJSONResponse().toJSONString(), AuthTokenBean.class);

        if (token != null && config.useCache4AuthenticationToken()) {
            JcsCacheUtil.getInstance().put(TOKEN_CACHE_KEY, token, TOKEN_CACHE_REGION);
        }
        return token;
    }
}

AuthTokenBean is a simple POJO:

public class AuthTokenBean {
    @SerializedName("access_token")  private String accessToken;
    @SerializedName("token_type")    private String tokenType;
    @SerializedName("expires_in")    private String expiresIn;
    @SerializedName("ext_expires_in") private String extExpiresIn;
    // getters/setters omitted
}

Coveo Search Token (User-Specific, JSUI)

The search token is what the Coveo JavaScript UI uses. It is generated by POSTing to Coveo’s /token endpoint with the user’s identity, entitlement groups, and a filter expression. This is the heart of content gating.

private RestRequest buildSearchTokenRequest(SearchTokenPayload payload) {
    RestRequest req = new RestRequest();
    req.setUrl(coveoSearchTokenEndpoint + "/token");
    req.setHeader("Content-Type", "application/json");
    req.setHeader("Authorization", "Bearer " + coveoApiKey); // server-side key

    JSONObject body = new JSONObject();
    body.put("searchHub",   payload.getSearchHub());
    body.put("validFor",    payload.getValidFor());   // token TTL in seconds
    body.put("userIds",     toJson(payload.getUserIds()));
    body.put("userGroups",  toJson(payload.getUserGroups()));
    body.put("filter",      payload.getFilter());

    req.setParamObject(body);
    return req;
}

SearchTokenBean carries the result back to the caller and into the HTTP session:

public class SearchTokenBean {
    private String  token;
    private String  organizationId;
    private String  searchHub;
    private boolean isLoggedIn;
    private boolean isRdcPrivileged;   // example: resource-download center membership
    private boolean canDownload;
    private boolean canBookmark;
    private boolean canViewDetails;
    private boolean isCompetitor;
    private Long    expirationTime;    // wall-clock ms, set by AEM (not Coveo)
    // getters/setters omitted
}

Session caching with a TTL check:

@Override
public SearchTokenBean getSearchToken(HttpRequest request, boolean forceRefresh) {
    if (!forceRefresh && config.useSession4SearchToken()) {
        Object cached = request.getSession().getAttribute(SEARCH_TOKEN_SESSION_KEY);
        if (cached instanceof SearchTokenBean) {
            SearchTokenBean bean = (SearchTokenBean) cached;
            if (System.currentTimeMillis() < bean.getExpirationTime()) {
                return bean;  // cache hit
            }
        }
    }
    // generate fresh token ...
    bean.setExpirationTime(System.currentTimeMillis() + ONE_HOUR_MS);
    request.getSession().setAttribute(SEARCH_TOKEN_SESSION_KEY, bean);
    return bean;
}

Part 3: Entitlement Groups and Content Gating

The most powerful aspect of AEM + Coveo is the ability to gate content at query time. Instead of filtering results after the fact, you pass user group memberships to Coveo when generating the token. Coveo’s index then enforces visibility rules so users only see documents their groups are allowed to see.

The SearchTokenServiceImpl builds the group set from multiple sources:

private Set<String> buildEntitlementGroups(EntitledUser user) {
    Set<String> groups = new HashSet<>();

    // Always-present baseline groups
    groups.add("acl:public:anonymous");
    groups.add("acl:public:all");
    groups.addAll(environmentSpecificEntitlements); // e.g. "env:staging"

    if (user == null) return groups; // anonymous user stops here

    // Registered-user groups
    if (user.isPartner()) {
        groups.add("program:partner");
        groups.add("user:" + user.getEnterpriseId());
    }
    if (user.isInternal()) {
        groups.add("role:internal:all-employees");
        if (user.isBlueBadge()) {
            groups.add("role:internal:full-time");
        }
    }
    if (user.isCompetitor()) {
        groups.add("role:competitor");
    }
    // ... additional program memberships, distribution roles, etc.

    groups.removeIf(StringUtils::isEmpty);
    return groups;
}

Partner Filter Expressions

Beyond groups, filter expressions restrict which indexed fields a user can see. For partner portals this is common different partner tiers see different documents:

// In Coveo query syntax, this says:
// "Exclude any document that targets a partner tier UNLESS it matches the user's tier"
private String buildPartnerFilter(String userSpecialty, String userRoleTier) {
    String filter = "NOT @partnertarget";  // exclude docs targeting specific partners...
    if (StringUtils.isNotEmpty(userSpecialty)) {
        // ...unless the user has this specialty
        filter += " OR @partnertarget==(\"" + userSpecialty + "\")";
    }
    if (StringUtils.isNotEmpty(userRoleTier)) {
        // ...or this role tier (also include "All Partners" catch-all)
        filter += " OR @partnertarget==(\"" + userRoleTier + "\",\"All Partners\")";
    }
    return filter;
}

This filter is injected into the token payload, so Coveo applies it server-side at query time.

Part 4: Querying Coveo from AEM (Search Service)

The Service Interface

public interface CoveoSearchService {
    SearchResults search(SearchInputs inputs, ExecutionContext ctx);
    SearchResults search(SearchInputs inputs, ExecutionContext ctx, String searchHub);
}

The Search Payload

CoveoSearchPayload maps directly to Coveo’s REST search request body:

public class CoveoSearchPayload {
    private String   q;                          // free-text query
    private String   aq;                         // advanced query (field expressions)
    private int      firstResult;                // pagination offset
    private int      numberOfResults;            // page size
    private String   locale;                     // e.g. "en-US"
    private String   timezone;                   // e.g. "America/Chicago"
    private String   sortCriteria;               // e.g. "@lastmodifieddt descending"
    private boolean  allowQueriesWithoutKeywords;
    private String[] fieldsToInclude;            // limit which metadata fields are returned
    private Facet[]  facets;
    private String   pipeline;
    private String   searchHub;
    // getters/setters omitted
}

Building the Advanced Query

The AEM service translates Java objects into Coveo’s query expression syntax:

private static String buildAdvancedQuery(FieldCriteria[] criteria, String customQuery) {
    StringBuilder sb = new StringBuilder();
    if (criteria != null) {
        for (FieldCriteria c : criteria) {
            switch (c.getOperator()) {
                case EQUALS:
                    sb.append("(@").append(c.getField()).append("==").append(c.getValue()).append(")");
                    break;
                case GREATER_THAN:
                    sb.append("(@").append(c.getField()).append(">").append(c.getValue()).append(")");
                    break;
                case NOT_EMPTY:
                    sb.append("(@").append(c.getField()).append(")");
                    break;
                case STARTS_WITH:
                    sb.append("(@").append(c.getField()).append("=\"").append(c.getValue()).append("\"*)");
                    break;
                // LESSER_THAN, GREATER_OR_EQUALS, LESSER_OR_EQUALS follow the same pattern
            }
        }
    }
    if (StringUtils.isNotEmpty(customQuery)) {
        sb.append(customQuery);
    }
    return sb.length() > 0 ? sb.toString() : null;
}

Example 1: find all published technical articles modified in the last 30 days:

FieldCriteria recentFilter = new FieldCriteria(
    "lastmodifieddt",
    FieldOperator.GREATER_OR_EQUALS,
    "2024/01/01"
);

SearchInputs inputs = new SearchInputs();
inputs.setFieldCriteria(new FieldCriteria[]{recentFilter});
inputs.setFieldsToInclude(new String[]{"title", "description", "clickableuri"});
inputs.setNumberOfResults(20);
inputs.setSortCriteria(new SortCriteria(SortStrategy.FIELD, "lastmodifieddt", SortDirection.DESCENDING));

SearchResults results = coveoSearchService.search(inputs, executionContext);

Example 2: Handling Large Result Sets (>1 000 results)

Coveo’s API caps a single response at 1 000 documents. To retrieve more, the service uses cursor based pagination via the sysrowid field:

// First request: sort by sysrowid ascending, include sysrowid in fields
payload.setSortCriteria("@sysrowid ascending");
payload.setFieldsToInclude(new String[]{"title", "description", "sysrowid"});

do {
    RestResponse response = restClient.executePost(buildRequest(payload, token));
    SearchResults chunk = gson.fromJson(response.getStringResponse(), SearchResults.class);
    allResults.addAll(chunk.getResults());

    if (chunk.getResults().size() < batchSize) break; // last page

    String lastRowId = chunk.getResults()
        .get(chunk.getResults().size() - 1).getSysrowid();

    // Next request: add a GREATER_THAN filter on sysrowid
    payload.setAq("(@sysrowid>" + lastRowId + ")");
} while (allResults.size() < maxResults);

This pattern avoids Coveo’s firstResult offset limit (which also caps at 1 000) and is reliable for batch export scenarios like scheduled jobs that sync Coveo product data to AEM.

Part 5: Sort Criteria

SearchInputs supports named sort presets that translate to Coveo field expressions:

  • Relevancy → relevancy
  • Newest → @lastmodifieddt descending
  • Oldest → @lastmodifieddt ascending
  • AtoZ → @title ascending
  • ZtoA → @title descending
  • Popularity → qre (Query Ranking Expression)
  • NoSort → nosort

The sort builder:

private static String buildSortCriteria(SortCriteria criteria) {
    if (criteria == null) return null;
    switch (criteria.getStrategy()) {
        case RELEVANCY: return "relevancy";
        case QRE:       return "qre";
        case NO_SORT:   return "nosort";
        case FIELD:
            if (StringUtils.isEmpty(criteria.getField())) return "relevancy";
            return "@" + criteria.getField() + " " +
                   (criteria.getSortDirection() == SortDirection.ASCENDING ? "ascending" : "descending");
        default: return "relevancy";
    }
}

Part 6: The Coveo JSUI Filter Blade (Front-end Integration)

The Coveo JavaScript UI framework is initialized from a JSP component that receives configuration from an AEM custom tag. The SearchBean is the bridge between AEM’s Java layer and the JS layer.

SearchBean the Configuration Bridge

public class SearchBean {
    private String coveoRestUrl;      // Coveo endpoint URL
    private String coveoToken;        // short-lived user search token
    private String searchHub;         // named search hub in Coveo
    private String searchAnalyticUrl; // UA endpoint
    private String pipeline;          // Coveo ML pipeline name
    private String language;
    private String localCode;
    private String pageResultCount;   // items per page
    private String dataLayout;        // "grid" | "list" | "table"
    private String onloadSortCriteria;

    // Facet configuration (left-rail filters)
    private List<SearchFacetProperties> searchFacetPropertiesList;

    // Column/card configuration (result layout)
    private List<SearchResultData> gridSearchResultDataList;

    // ... getters/setters
}

SearchFacetProperties Configuring Facets

public class SearchFacetProperties {
    private String facetClass;    // CSS class / Coveo component class
    private String dataField;     // Coveo field e.g. "@contenttypes"
    private String dataTitle;     // Display label
    private String dataId;        // Unique facet ID (used by Coveo routing)
    private String basePath;      // For hierarchical facets
    private String initialLevel;  // Starting depth for hierarchical facets
    // getters/setters omitted
}

SearchResultData Configuring Result Columns

public class SearchResultData {
    private String dataField;      // Coveo field to display
    private String dataTitle;      // Column header label
    private String dateFormat;     // For date fields
    private String displayResults; // Visibility toggle
    private String listFromMulti;  // Render multi-value field as a list
    // getters/setters omitted
}

SearchTag Injecting Coveo Config via Sling Model

The Sling Model reads global Coveo settings and exposes them to the HTL template. In older AEM projects this was done via a JSP custom tag; the logic is equivalent, only the wiring differs:

public class SearchTag extends BaseComponentTag {
    @Override
    public void doTag() throws JspException, IOException {
        super.doTag();
        SearchConfigBean config = new SearchConfigBean();

        // These come from a global settings service, not hardcoded
        config.setSearchEndpointUrl(getCoveoSearchEndpointUrl());
        config.setSearchAnalyticUrl(getCoveoSearchAnalyticsUrl());
        config.setSearchOrganizationUrl(getCoveoOrganizationId());
        config.setSearchHub(getSettingProperty("searchHub"));
        config.setSearchScriptLibrary(getSettingProperty("searchScriptLibrary"));
        config.setSearchStyleLibrary(getSettingProperty("searchStyleLibrary"));

        pageContext.setAttribute("replatformInfo", config);
    }
}

In modern AEM projects using HTL (Sling Templating Language), the SearchBean is exposed through a Sling Model instead of a JSP tag, and the markup is rendered in an .html HTL template. The pattern is equivalent only the templating syntax changes.

he Sling Model adapter:

@Model(adaptables = SlingHttpServletRequest.class,
       defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class SearchModel {

    @OSGiService
    private SearchTokenService searchTokenService;

    @OSGiService
    private CoveoSearchGlobalService coveoSearchGlobalService;

    @ScriptVariable
    private Page currentPage;

    private SearchBean searchBean;

    @PostConstruct
    protected void init() {
        searchBean = new SearchBean();
        searchBean.setCoveoRestUrl(coveoSearchGlobalService.getSearchEndpointUrl());
        searchBean.setSearchHub(coveoSearchGlobalService.getSearchHub());
        // token generation happens here — keeps it off the template
        JSONObject token = searchTokenService.generateSearchTokenForUser(executionContext);
        if (token != null) {
            searchBean.setCoveoToken(token.get("token").toString());
        }
    }

    public SearchBean getSearchBean() { return searchBean; }
}

The HTL template (search.html):

<sly data-sly-use.model="com.yourcompany.search.models.SearchModel"
     data-sly-use.template="core/wcm/components/commons/v1/templates.html" />

<!-- Coveo CSS and JS loaded from the version-controlled CDN URL -->
<link rel="stylesheet" href="${model.searchBean.searchStyleLibrary}" />
<script src="${model.searchBean.searchScriptLibrary}"></script>

<script>
  document.addEventListener("DOMContentLoaded", function () {
    Coveo.SearchEndpoint.configureCloudV2Endpoint(
      "${model.searchBean.searchOrganizationId @ context='scriptString'}",
      "${model.searchBean.coveoToken @ context='scriptString'}",
      "${model.searchBean.coveoRestUrl @ context='scriptString'}"
    );
    Coveo.init(document.getElementById("search"));
  });
</script>

<div id="search"
     class="CoveoSearchInterface"
     data-search-hub="${model.searchBean.searchHub}"
     data-pipeline="${model.searchBean.pipeline}">

  <div class="CoveoSearchBox"></div>
  <div class="CoveoFacet"
       data-sly-list.facet="${model.searchBean.searchFacetPropertiesList}"
       data-field="${facet.dataField}"
       data-title="${facet.dataTitle}">
  </div>
  <div class="CoveoResultList"></div>
</div>

Note the @context=’scriptString’ XSS context on values rendered inside <script> blocks. HTL requires this to apply the correct escaping.

Dropdown Data Source Servlet

Authors configure facets through the AEM dialog. A datasource servlet populates the dropdown options by reading from the JCR (component configuration nodes) rather than hardcoding values:

@Component(immediate = true, service = Servlet.class, property = {
    "sling.servlet.resourceTypes=your-app/components/coveo-filter-blade",
    "sling.servlet.selectors=coveo-facets",
    "sling.servlet.extensions=json"
})
public class CoveoFacetDropdownServlet extends SlingSafeMethodsServlet {
    @Override
    protected void doGet(SlingHttpServletRequest req, SlingHttpServletResponse resp) {
        Resource configResource = req.getResourceResolver()
            .getResource("/apps/your-app/settings/coveo/facets");
        // iterate children, build DataSource, write JSON
    }
}

Part 7: Sitemap XML with Coveo Metadata

For content types managed in AEM but not natively crawlable by Coveo (e.g., events stored in Content Fragments), you can generate a custom XML sitemap that includes <coveo:metadata> tags. Coveo’s crawler reads these and adds the fields to the search index.

@Component(immediate = true, service = Servlet.class, property = {
    "sling.servlet.paths=/apps/your-app/services/eventsitemap",
    "sling.servlet.extensions=xml",
    "sling.servlet.methods=GET"
})
public class EventsSitemapServlet extends SlingSafeMethodsServlet {

    private static final String XML_HEADER =
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
        "<urlset\n" +
        "  xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"\n" +
        "  xmlns:coveo=\"https://www.coveo.com/schemas\">\n";

    @Override
    protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("application/xml; charset=UTF-8");

        StringBuilder sitemap = new StringBuilder(XML_HEADER);
        sitemap.append(buildEventEntries(request.getResourceResolver()));
        sitemap.append("</urlset>");

        response.getWriter().println(sitemap);
    }

    private StringBuilder buildEventEntries(ResourceResolver resolver) {
        StringBuilder entries = new StringBuilder();
        // iterate Content Fragment nodes under the events root path
        // for each event, build a <url> block
        for (EventContentFragment event : loadEvents(resolver)) {
            entries.append("<url>\n");
            entries.append("  <loc>").append(event.getUrl()).append("</loc>\n");
            entries.append("  <coveo:metadata>\n");
            entries.append("    <title><![CDATA[").append(event.getName()).append("]]></title>\n");
            entries.append("    <description><![CDATA[").append(event.getDescription()).append("]]></description>\n");
            entries.append("    <eventstartdate>").append(event.getStartDate()).append("</eventstartdate>\n");
            entries.append("    <eventenddate>").append(event.getEndDate()).append("</eventenddate>\n");
            entries.append("    <timeZone>").append(event.getTimeZone()).append("</timeZone>\n");
            entries.append("  </coveo:metadata>\n");
            entries.append("</url>\n");
        }
        return entries;
    }
}

The <coveo:metadata> block tells Coveo’s crawler to index custom fields alongside the standard <loc>. You then map these in the Coveo source configuration to Coveo fields like @eventstartdate.

Part 8: Putting It All Together Request Flow

Here is the full request flow for a page that uses Coveo JSUI:


Browser (page request)
  │
  ▼
AEM Dispatcher
  │
  ▼
AEM Publish (JSP rendering)
  │
  ├─► SearchTag.doTag()
  │     └─► reads Coveo endpoint/org/hub from global settings (JCR)
  │
  ├─► SearchTokenService.generateSearchTokenForUser(request)
  │     ├─► loads EntitledUser from SSO/profile service
  │     ├─► builds entitlement groups (public + program memberships + roles)
  │     ├─► builds partner filter expression
  │     └─► POSTs to Coveo /token endpoint → returns short-lived JWT
  │
  └─► JSP renders HTML with:
        - Coveo JS/CSS from CDN
        - <script> bootstrap with organizationId + search token
        - Coveo component markup (facets, result list, search box)
  │
  ▼
Browser receives page
  │
  ▼
Coveo JS Framework initializes
  │
  └─► sends search request to Coveo REST API
        Bearer: <search token>   ← user identity + groups + filter baked in
        Body: { q, aq, facets, ... }
  │
  ▼
Coveo returns results
  └─► only documents the user's groups are permitted to see

For server-side search (e.g., a scheduled job or server-rendered content collection):


AEM scheduled job / servlet
  │
  ├─► CoveoSearchService.search(inputs, ctx)
  │     ├─► SearchTokenService.generateSearchTokenForUser(ctx)  ← gets JWT
  │     ├─► CoveoSearchServiceImpl.buildCoveoSearchPayload(inputs)
  │     │     ├─► buildAdvancedQuery(fieldCriteria, customQuery)
  │     │     └─► buildSortCriteria(sortCriteria)
  │     └─► POSTs to Coveo /v2?organizationId=xxx
  │           Authorization: Bearer <search token>
  │
  └─► returns SearchResults → rendered on page or processed in job

Key Takeaways

  • Never expose your Coveo API key to the browser: Always use short-lived search tokens generated server-side. The API key only lives on the AEM author/publish tier.
  • Cache strategically: Auth tokens are cached in a distributed JCS region (shared across cluster nodes). Search tokens are cached in the HTTP session per user, with TTL enforcement on the AEM side.
  • Entitlement groups are your security boundary: Coveo enforces visibility based on the groups in the token. Build your group taxonomy carefully and keep it consistent between what you index and what you grant in tokens.
  • Use the “sysrowid” cursor for large exports: Coveo’s “firstResult” offset limit and the 1 000 result page cap mean any batch job retrieving more than 1 000 documents must use cursor based pagination.
  • Coveo metadata in sitemaps: is an underused feature that lets you index AEM content fragments and other non HTML assets without a custom Coveo connector.
  • OSGi configuration: is the right place for environment-specific settings (endpoint URLs, search hub names, entitlement group names). JCR is the right place for encrypted credentials.

Suggested Project Structure


your-aem-project/
└── core/
    └── src/main/java/com/yourcompany/
        ├── search/
        │   ├── constants/
        │   │   └── SearchConstants.java          # enums, JCR node names
        │   ├── beans/
        │   │   ├── request/
        │   │   │   ├── SearchInputs.java          # inputs builder
        │   │   │   ├── CoveoSearchPayload.java    # REST body
        │   │   │   ├── FieldCriteria.java
        │   │   │   └── SortCriteria.java
        │   │   └── response/
        │   │       ├── SearchResults.java
        │   │       └── Result.java
        │   ├── services/
        │   │   ├── CoveoSearchService.java        # interface
        │   │   ├── SearchTokenService.java        # interface
        │   │   └── impl/
        │   │       ├── CoveoSearchServiceImpl.java
        │   │       └── SearchTokenServiceImpl.java
        │   └── osgiconfig/
        │       ├── SearchTokenServiceConfig.java
        │       └── SearchGlobalServiceConfig.java
        ├── gateway/
        │   ├── services/
        │   │   ├── SearchGatewayService.java      # optional: API gateway layer
        │   │   └── impl/
        │   │       └── SearchGatewayServiceImpl.java
        │   └── osgiconfig/
        │       └── SearchGatewayServiceConfig.java
        └── ui/
            ├── beans/
            │   ├── SearchBean.java               # JSUI config bridge
            │   ├── SearchFacetProperties.java
            │   └── SearchResultData.java
            ├── tags/
            │   └── SearchTag.java                # JSP custom tag
            └── servlets/
                ├── CoveoFacetDropdownServlet.java # AEM dialog datasource
                └── EventsSitemapServlet.java      # Coveo XML sitemap

Coveo’s combination of token based security, rich query syntax, and a powerful JavaScript UI framework make it an excellent fit for large scale AEM deployments. The patterns described here layered token generation, entitlement based content gating, cursor pagination, and sitemap based indexing have been proven at scale and represent a solid foundation for any team building search on top of AEM.

A Note on “JcsCacheUtil”

Throughout this post, “JcsCacheUtil” is used to cache auth tokens across requests. This is not a standard library class. it is a project specific wrapper around Apache JCS (Java Caching System), an open-source distributed caching framework. If you are implementing this pattern from scratch, you have several alternatives:

  • Apache JCS directly: JCS.getInstance(“regionName”)` gives you a CacheAccess object with get/put methods. Suitable if you want the same underlying library.
  • Caffeine: a high performance in-memory cache with a clean API and built in TTL support. A common choice for OSGi bundles in modern AEM projects.
  • Ehcache: more feature rich, supports off-heap and disk tiers. Better if you need large cache regions.
  • AEM’s Sling Commons Cache: available as an OSGi service on AEM, integrates naturally with the container lifecycle.

Whichever you choose, the key design principle remains the same: cache the auth token (long-lived, shared across all users) in a cluster-aware cache region, and cache the search token (short-lived, user-specific) in the HTTP session with a wall clock TTL check on read.


메타데이터
post_id
71ea486ce287
slug
integrating-coveo-search-with-adobe-experience-manager-aem-a-deep-dive-71ea486ce287
url
https://medium.com/@frannet82/integrating-coveo-search-with-adobe-experience-manager-aem-a-deep-dive-71ea486ce287
canonical_url
https://medium.com/@frannet82/integrating-coveo-search-with-adobe-experience-manager-aem-a-deep-dive-71ea486ce287
author_url
https://medium.com/@frannet82
status
ok
fetched_at
2026-06-10 08:17:25