Expect-CT Deprecation and Safe Removal

This guide is part of the Deprecated Security Headers & Legacy Browser Support reference. The Expect-CT response header once let a site opt in to Certificate Transparency (CT) enforcement before browsers required it — a certificate presented without valid Signed Certificate Timestamps (SCTs) would be rejected, and violations could be reported. That transitional job is finished: Chrome has enforced CT for all publicly-trusted certificates since 2018, Safari enforces its own CT policy, and Chrome removed Expect-CT support entirely in Chrome 107 (October 2022). Every browser that ever honored the header now ignores it. This page shows why it is obsolete, how to remove it from each platform without breaking anything, and what CT-log monitoring replaces it with.

Expect-CT lifecycle from opt-in to removal A left-to-right timeline showing Expect-CT introduced in 2017, default Chrome CT enforcement in 2018, deprecation, and full removal in Chrome 107 in 2022. 2017 Expect-CT opt-in ships 2018 Chrome enforces CT by default 2021 Deprecation announced 2022 Chrome 107 removes support The header was a bridge; default enforcement replaced the bridge header honored ignored
Expect-CT bridged the gap before browsers enforced CT by default; once they did, the header had nothing left to do.

Configuration Syntax & Exact Values

The header is defined by RFC 9163 and takes three directives. For completeness — and so you recognize what you are removing — the full grammar is:

Expect-CT: max-age=86400, enforce, report-uri="https://example.com/ct-report"

Breaking that down:

Directive Role Effect today
max-age Required. Seconds the policy is cached by the client. Ignored — no client caches it.
enforce Optional. Reject connections whose certificate lacks valid SCTs. Ignored — enforcement is unconditional and built in.
report-uri="..." Optional. URL to POST JSON violation reports to. Ignored — no browser emits Expect-CT reports.

A report-only policy omitted enforce:

Expect-CT: max-age=86400, report-uri="https://example.com/ct-report"

There is no correct value to set anymore. Every modern browser parses the header and discards it; Chrome, Edge, and other Chromium builds at version 107 or later do not even read it. The only correct action is deletion. Leaving it in place is not dangerous, but it is dead weight that misleads audits and implies a control that no longer exists.

Do not confuse Expect-CT with HSTS or Content Security Policy: those remain active, enforced controls. Expect-CT is inert.

Server-Side Configuration

The task is subtractive: find where Expect-CT is injected and stop injecting it. Because it may be added at the app, the web server, or the CDN, remove it at whichever layer set it — and verify at the edge, since that is the value the browser would have seen.

Nginx

If the header was set with add_header, delete that line. To defensively strip an Expect-CT arriving from an upstream application, hide it at the proxy:

server {
    listen 443 ssl;
    server_name example.com;

    # 1. Delete any local add_header Expect-CT line entirely (do not leave it).

    location / {
        proxy_pass http://127.0.0.1:3000;
        # Strips Expect-CT injected by the upstream app before the response
        # leaves Nginx. proxy_hide_header is the reliable removal primitive.
        proxy_hide_header Expect-CT;
    }
}

If you use the headers_more module, more_clear_headers removes it regardless of which layer added it, including Nginx’s own add_header:

# Requires the ngx_headers_more module. Clears the header on every response,
# even one added elsewhere in the config.
more_clear_headers "Expect-CT";

After editing, reload rather than restart to avoid dropping connections: nginx -t && nginx -s reload.

Apache

Remove the setting line, and add an explicit unset to strip any upstream copy. The always condition matters because it applies the removal to error responses (4xx/5xx) as well as 200s — an app can attach the header to an error page only:

<VirtualHost *:443>
    ServerName example.com

    # Delete any prior "Header always set Expect-CT ..." line.

    # 'always' applies the unset to ALL responses including 4xx/5xx, not just
    # 2xx/3xx (which is all the default 'onsuccess' table would cover).
    Header always unset Expect-CT
</VirtualHost>

Reload with apachectl configtest && apachectl graceful.

Cloudflare

If Cloudflare injected the header via a Transform Rule or the legacy Managed Transforms, delete that rule. To strip an Expect-CT forwarded from your origin, add an HTTP Response Header Modification rule:

{
  "action": "rewrite",
  "action_parameters": {
    "headers": {
      "Expect-CT": { "operation": "remove" }
    }
  },
  "expression": "true",
  "description": "Remove obsolete Expect-CT from all responses"
}

The "operation": "remove" is the removal verb; "expression": "true" applies it to every response the zone serves. Deploy through Rules → Transform Rules → Modify Response Header.

Node.js / Express (Helmet)

Helmet shipped an expectCt middleware that is removed in Helmet 6 and later. If you are on an older Helmet, disable it explicitly; if you set the header by hand, delete the res.setHeader call:

// Helmet 5 and earlier: turn the middleware off explicitly.
app.use(helmet({ expectCt: false }));

// If you set it manually anywhere, delete the line — do not leave it:
//   res.setHeader('Expect-CT', 'max-age=86400, enforce');

Upgrading to Helmet 6+ removes the directive entirely, which is the cleaner fix.

Django / FastAPI

If you emitted the header from middleware, remove the assignment. A defensive stripping middleware guarantees no rogue upstream value survives:

# Django middleware — deletes Expect-CT from every response if present.
class RemoveExpectCTMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        # response.headers is case-insensitive; pop is a no-op if absent.
        response.headers.pop("Expect-CT", None)
        return response

Diagnostic & Verification Steps

Confirm the header is gone at the edge, and confirm — separately — that Certificate Transparency is still being satisfied by your certificate. These are two different checks.

First, verify the header no longer ships. grep -i catches any casing:

curl -sI https://example.com | grep -i expect-ct

Expected output: nothing. An empty result means no Expect-CT header is present, which is the goal:

If it still appears, you removed it at the wrong layer — re-check the origin and the CDN independently by testing the origin IP directly:

curl -sI --resolve example.com:443:203.0.113.10 https://example.com | grep -i expect-ct

Second, confirm your certificate actually carries embedded SCTs, which is what CT enforcement checks. openssl prints the SCT list from the certificate:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -text | grep -A1 "CT Precertificate SCTs"

Expected output confirms SCTs are embedded — a certificate from any modern public CA will show this:

CT Precertificate SCTs:
    Signed Certificate Timestamp:
How browsers enforce Certificate Transparency by default A flow from the TLS handshake through SCT extraction and count check to the accept-or-block decision, all performed without any Expect-CT header. TLS handshake cert presented Extract SCTs cert / OCSP / TLS ext Enough valid SCTs? Connection OK Blocked CT error page yes no No header is consulted at any step
Default CT enforcement runs entirely inside the browser's certificate validation; Expect-CT was never part of this path once enforcement shipped.

Third, in DevTools → Security panel, load the page and inspect the certificate entry. Chrome shows Certificate Transparency: compliant for a valid public certificate. That status is produced by the built-in policy, not by any header you send.

Edge Cases, Security Implications & Safe Rollback

The modern replacement for Expect-CT’s detection role is continuous CT-log monitoring. Every publicly-trusted certificate is logged to append-only Certificate Transparency logs; a monitor watches those logs for your domain and alerts on unexpected issuance.

CT-log monitoring replaces Expect-CT reporting A comparison contrasting the obsolete Expect-CT report path against the modern flow where CT logs feed a monitor that alerts on unexpected certificate issuance. Obsolete: Expect-CT reports Browser report-uri never fires Modern: CT-log monitoring CA issues certificate CT logs append-only Monitor crt.sh / API Alert you
Detection moves from a browser-driven report that never arrives to a monitor that watches the public CT logs for any certificate issued against your domain.

Set up monitoring with a hosted service (Cloudflare’s Certificate Transparency alerts, Cert Spotter, Facebook’s CT monitoring, or a scheduled query against crt.sh) and pin issuance with a CAA record:

# Poll crt.sh for every certificate logged for your domain; alert on new IDs.
curl -s 'https://crt.sh/?q=example.com&output=json' \
  | jq -r '.[] | "\(.id) \(.name_value) \(.issuer_name)"' | sort -u
# CAA DNS record constraining issuance to named CAs — a BIND zone-file line.
example.com.  IN  CAA  0 issue "letsencrypt.org"

Conclusion

Treat this as a low-risk cleanup, but stage it like any header change: remove Expect-CT in staging first and confirm with curl -sI that it is gone at both the origin and the edge, roll it to production, then stand up CT-log monitoring and a CAA record so the detection role the header pretended to fill is actually covered. Because the header is inert, there is no enforcement window and no lockout risk — the only diligence required is confirming that no audit tooling or dashboard still expects to see it before you delete the line for good.

Frequently Asked Questions

Is it safe to remove Expect-CT from production right now? Yes. Every browser that ever honored the header now ignores it, and Chrome removed support in version 107 (October 2022). Certificate Transparency enforcement continues unchanged because it is built into browser certificate validation, not driven by the header. Removal cannot break TLS or lock clients out.

Does deleting Expect-CT disable Certificate Transparency enforcement? No. Chrome and Safari enforce CT for all publicly-trusted certificates automatically. The header only ever existed to opt in early, before default enforcement shipped. Deleting it changes nothing about whether your certificate’s SCTs are checked.

Why is my report-uri endpoint silent — is CT working? The silence tells you nothing about CT health. No browser has emitted Expect-CT reports since the header was deprecated, so a zero-traffic endpoint is expected, not reassuring. Decommission it and use CT-log monitoring to detect mis-issuance instead.

What replaces Expect-CT for catching mis-issued certificates? Continuous CT-log monitoring. Every publicly-trusted certificate is logged to append-only CT logs; a monitor such as Cert Spotter, Cloudflare’s CT alerts, or a scheduled crt.sh query watches those logs for your domain and alerts on any certificate you did not request. Pair it with a CAA DNS record to constrain which CAs may issue.

Does Firefox enforce Certificate Transparency? Firefox has never enforced CT and never honored Expect-CT, so it neither rejected non-compliant certificates nor sent reports. Removing the header has no effect on Firefox. Your CT protection in practice comes from Chromium-based browsers and Safari, plus the CT-log monitoring you run yourself.