http_security_pinning
TLS certificate pinning for Flutter. HttpSecurityPinningClient is a drop-in
dart:io HttpClient (usable with package:http, package:dio and
HttpOverrides) that only completes a TLS connection when the server's
public key matches one of your SPKI SHA-256 pins.
On Flutter Web, where browsers do not allow TLS pinning, the package instead verifies Ed25519 signatures on responses made by your server. This is a different, weaker guarantee; read Flutter Web before relying on it.
Warning
Pinning trades flexibility for protection. If the server's keys change and none of your pins matches any more, every pinned request fails until users install an app update. Before you pin, read Best practices, always ship a backup pin, and make sure you control the keys you pin.
Contents
- Platform support
- Installation
- Getting your pins
- Usage
- How it works (security model)
- Flutter Web: response signatures, not TLS pinning
- Debugging and
badCertificateCallback - Error handling
- Configuration reference
- Best practices
- Testing
Platform support
| Platform | Mechanism | Certificates the pins are compared with |
|---|---|---|
| Android (API 24+) | Native TLS probe, then the dart:io request trusts only the matching certificates |
The chain the server sends: leaf and intermediates. Servers rarely send their root, so do not rely on root pins. |
| iOS (13+) | Native TLS probe (NSURLSession + SecTrust), then the same as Android |
The chain built by iOS: leaf, intermediates and usually the root (which may come from the device trust store). |
| macOS, Windows, Linux | Pure dart:io probe |
Only the leaf certificate. Intermediate and root pins can never match on desktop. |
| Web | No TLS pinning (browsers do not allow it). Ed25519 response-signature check (required unless explicitly disabled) | Not applicable: HttpSecurityPinningClient.isSupported is false on the web. |
Pins configured for a host are therefore portable across platforms only if they include the leaf key (plus a backup key).
Installation
dependencies:
http_security_pinning: ^2.0.0
Requirements: Flutter 3.41 or newer, Android minSdk 24, iOS 13.
Upgrading from 1.x? Version 2.0.0 contains breaking security fixes: see the migration guide in the changelog.
Getting your pins
A pin is the base64 SHA-256 digest of a certificate's DER encoded
SubjectPublicKeyInfo (RFC 7469). sha256/ prefixes and URL-safe or
unpadded base64 are accepted too.
From the server, with OpenSSL
# Every certificate the server sends, leaf first, with its pin.
openssl s_client -servername api.example.com -connect api.example.com:443 -showcerts </dev/null 2>/dev/null \
| awk '/BEGIN CERT/{n++} n>0 {print > ("cert" n ".pem")}'
for f in cert*.pem; do
echo "$f: $(openssl x509 -in "$f" -noout -subject)"
openssl x509 -in "$f" -pubkey -noout | openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary | openssl enc -base64
done
On Windows, run these commands in Git Bash (it ships OpenSSL).
For a backup key that is not deployed yet
Generate the next key pair now, keep it offline, and pin it in the app so that you can switch to it without an app update:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out next.key
openssl pkey -in next.key -pubout -outform der | openssl dgst -sha256 -binary | openssl enc -base64
From the app (any build mode)
Connect once with a pin that cannot match: the error lists what the server
presented (observedPins, leaf first). Verify the values out of band (for
example with OpenSSL) before shipping them.
import 'package:http_security_pinning/http_security_pinning.dart';
Future<List<String>> discoverPins(Uri url) async {
final client = HttpSecurityPinningClient([
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // matches nothing
]);
try {
await (await client.getUrl(url)).close();
return const [];
} on NoValidPinsFoundException catch (e) {
return e.observedPins;
} finally {
client.close(force: true);
}
}
The example app has a Discover pins button doing exactly this.
Usage
All pins and keys in the snippets below are placeholders that match nothing: replace them with your own values. Never copy a pin or key from documentation, tests or fixtures into an app: their private keys may be public.
With package:http
import 'package:http/io_client.dart';
import 'package:http_security_pinning/http_security_pinning.dart';
Future<void> main() async {
final client = IOClient(HttpSecurityPinningClient([
'PRIMARY0PIN000000000000000000000000000000AA=', // your current key
'BACKUP00PIN000000000000000000000000000000AA=', // your backup key
]));
try {
final response = await client.get(Uri.parse('https://api.example.com/'));
print(response.statusCode);
} finally {
client.close();
}
}
An empty pin list throws an ArgumentError: it would disable pinning.
Several hosts, wildcards
import 'package:http_security_pinning/http_security_pinning.dart';
final client = HttpSecurityPinningClient.perHost(
{
'api.example.com': [
'PRIMARY0PIN000000000000000000000000000000AA=',
'BACKUP00PIN000000000000000000000000000000AA=',
],
// Any depth of subdomain (a.cdn.example.com, a.b.cdn.example.com),
// but NOT cdn.example.com itself: list it separately if needed.
'*.cdn.example.com': [
'CDN0PRIMARY0000000000000000000000000000000A=',
'CDN0BACKUP00000000000000000000000000000000A=',
],
},
// false (default): other hosts throw UnpinnedHostException.
// true: other hosts use the normal system trust store, without pinning.
allowUnpinnedHosts: false,
);
Host names are compared case-insensitively, without a trailing dot, and internationalized names are compared in their punycode form.
With package:dio
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:http_security_pinning/http_security_pinning.dart';
Future<void> main() async {
final dio = Dio();
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () =>
HttpSecurityPinningClient([
'PRIMARY0PIN000000000000000000000000000000AA=',
'BACKUP00PIN000000000000000000000000000000AA=',
]);
try {
final response = await dio.get<String>('https://api.example.com/');
print(response.statusCode);
} on DioException catch (e) {
// Dio wraps errors: the pinning exception is in `e.error`.
if (e.error is NoValidPinsFoundException) print('Pin mismatch');
rethrow;
} finally {
dio.close();
}
}
App-wide with HttpOverrides
HttpOverrides.global affects every dart:io HttpClient of the app
(including NetworkImage and packages built on dart:io). Use a per-host
policy with allowUnpinnedHosts: true so that hosts you do not pin (CDNs,
analytics, map tiles) keep working:
import 'dart:io';
import 'package:http_security_pinning/http_security_pinning.dart';
class PinnedHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
// `context` is ignored: pinned hosts use their own SecurityContext.
return HttpSecurityPinningClient.perHost(
{
'api.example.com': [
'PRIMARY0PIN000000000000000000000000000000AA=',
'BACKUP00PIN000000000000000000000000000000AA=',
],
},
allowUnpinnedHosts: true,
);
}
}
void main() {
HttpOverrides.global = PinnedHttpOverrides();
// runApp(...);
}
HttpOverrides does not affect native networking: WebViews,
cupertino_http, cronet_http, Firebase and other native SDKs. Pin those
with the platform's own mechanism (Android Network Security Config pin-set,
iOS NSPinnedDomains).
One client for mobile, desktop and web
UniversalSecurityClient returns an http.Client that pins TLS on Android,
iOS and desktop, and verifies response signatures on the web:
import 'package:http_security_pinning/http_security_pinning.dart';
final client = UniversalSecurityClient.create(
// Android, iOS, desktop: required, TLS SPKI pinning.
spkiPins: [
'PRIMARY0PIN000000000000000000000000000000AA=',
'BACKUP00PIN000000000000000000000000000000AA=',
],
// Web: required, the server's Ed25519 public keys (32 bytes each).
serverPublicKeys: [
PayloadVerifier.decodeBase64('SERVER0KEY0CURRENT000000000000000000000000A='),
],
);
Each platform throws an ArgumentError when the setting it relies on is
missing, instead of silently sending unprotected requests.
How it works (security model)
For every request, HttpSecurityPinningClient:
- Checks the policy for the target host: hosts not allowed by a per-host
policy throw
UnpinnedHostException, and a non-HTTPS URL to a pinned host throwsInsecureConnectionException. - Probes the server's certificate chain once per
host:port(cached for one hour by default, seeCertificateChainCache). No request is ever sent on the probe connection: Android, iOS and the desktop probe through a proxy abort the TLS handshake as soon as the chain is known; the direct desktop probe completes the handshake and closes the socket. WhenfindProxy(by default thehttp_proxy/https_proxyenvironment variables, likedart:io) chooses a proxy, the probe goes through the first proxy of the directive, without falling back to further entries. Credentials embedded in the directive (PROXY user:pass@host:port) are used; those set withaddProxyCredentialsare not. - Keeps only the certificates whose SPKI hash matches a pin. No match:
NoValidPinsFoundException(fail closed). - Sends the request on a connection whose only trust anchors are those
certificates (
SecurityContext(withTrustedRoots: false)). The TLS handshake still verifies the host name, the validity period and the signatures up to the matched certificate, before any request byte is sent. A different certificate presented on this connection, even one carrying a pinned key with a forged issuer, is rejected.
Consequences worth knowing:
- Pinning replaces the system CA check, it does not add to it. A certificate chaining to a pinned key is accepted even if the system does not trust its root; revocation and Certificate Transparency are not checked. Expired certificates and host name mismatches are still rejected.
- Redirects are followed by the client itself, so every hop goes through
steps 1–4. A redirect from HTTPS to HTTP throws
InsecureConnectionException; a redirect to a host the policy does not allow throwsUnpinnedHostException.abort()reaches the hop in flight.Authorization,Proxy-Authorizationand cookies are dropped on cross-origin redirects, likedart:iodoes, but custom headers such asX-API-Keyare forwarded to the next host the policy allows (withallowUnpinnedHosts: truethis can be an unpinned host, checked only by the system trust store): setfollowRedirects = falseon requests that carry such secrets if your API may redirect to another host. - A custom
connectionFactorymust return a plainSocketfor direct HTTPS connections to pinned hosts: the client secures it with the pinned context. ASecureSocketis refused, since how it was validated is unknown. The probe does not use the factory. - Host names are normalized before the policy lookup: case, a trailing
dot, Unicode vs punycode and every spelling of an IP address
(
127.1,0x7f.0.0.1,[0:0:0:0:0:0:0:1]) are equivalent. URLs whose host contains characters that are not valid in a host name are refused with anArgumentError. - Certificate renewal: after a TLS failure the chain is probed again once and the request retried. This helps only if the new key is already pinned.
- Failed probes (network errors, timeouts) are retried
retryCounttimes with a linear delay (retryDelay * attempt). With the defaults, an unreachable host fails after about 45 seconds (4 × 10 s plus delays); lowertimeout/retryCountfor interactive requests. - Pinning only protects connections made through this client. It can be bypassed on a rooted or jailbroken device by an attacker who controls the device; treat it as defence in depth, not as protection of the client itself.
Flutter Web: response signatures, not TLS pinning
Browsers do not let applications inspect or constrain TLS connections.
On the web, HttpSecurityPinningClient therefore does not pin
certificates (the SPKI pins are only validated for syntax). Instead your
server signs every response with an Ed25519 key, and the client verifies the
signature with the public keys you configure.
What this protects: the response body, status and content type, for the exact request that was sent (method, scheme, host, path and query, and a random per-request nonce), against modification or replay by intermediaries between your signing server and the browser (CDN, reverse proxy, TLS-inspecting middlebox). Other response headers are not signed. A response that the browser obtained by following a redirect is refused, because the redirect itself is not signed: call the final URL directly.
What it does not protect: confidentiality (anyone able to intercept TLS can still read requests and responses, including tokens), the request itself, and an attacker who can modify the JavaScript bundle of your app.
timestampTolerance only bounds how old a signature may be (in both
directions, to tolerate clock skew); replay protection comes from the nonce.
Client
import 'package:http_security_pinning/http_security_pinning.dart';
final client = UniversalSecurityClient.create(
spkiPins: ['PRIMARY0PIN000000000000000000000000000000AA=', 'BACKUP00PIN000000000000000000000000000000AA='],
serverPublicKeys: [
// Current key and the next one, so you can rotate without an update.
PayloadVerifier.decodeBase64('SERVER0KEY0CURRENT000000000000000000000000A='),
PayloadVerifier.decodeBase64('SERVER0KEY0NEXT000000000000000000000000000A='),
],
// Accepted clock difference; null disables the check for users with a
// badly set clock (the nonce still prevents replays).
timestampTolerance: const Duration(minutes: 5),
maxResponseBytes: 10 * 1024 * 1024, // responses are buffered to be verified
);
On the web, HttpSecurityPinningClient.perHost also accepts
serverPublicKeysByHost (keys per host pattern). A host without keys throws
UnpinnedHostException, unless allowUnpinnedHosts: true, in which case its
responses pass through unverified (as a stream: neither timeout nor
maxResponseBytes apply to their body). allowUnverifiedResponses: true
lets hosts without keys pass unverified (hosts with keys are still verified);
without any key it turns the check off explicitly. Internationalized host
names must be written in punycode on the web: a non-ASCII host never passes
unverified. These web-only parameters are accepted and ignored on other
platforms, so the same code compiles everywhere.
The hsp-sig-v2 signature
-
The client sends a random nonce in the
X-HSP-Noncerequest header. -
The server builds the signature base: UTF-8 text, one line per component, separated by
\n, no trailing newline:hsp-sig-v2 @method: GET @scheme: https @authority: api.example.com @request-target: /users/42?expand=true @status: 200 @nonce: AAECAwQFBgcICQoLDA0ODw @created: 1760169600000 content-type: application/json @body-sha256: F7TbBk4X9IeOORF35spiO3mJEfNAFLyeeJIJk9fdJ60=@method: request method, upper case.@scheme: request scheme, lower case (https).@authority: lower-case host (punycode, no trailing dot, IPv6 in brackets), plus:portonly for a non-default port. Use the public host the client called, not an internal one.@request-target: path (/if empty) and?queryif the URL has one, exactly as received on the wire (Node:req.url; Express:req.originalUrl), not a decoded or rewritten path. Browsers send'in a query as%27, and the client signs it that way.@status: response status code.@nonce: theX-HSP-Noncerequest header.@created: signing time, milliseconds since the Unix epoch.content-type: the responseContent-Type(trimmed), or empty. Sign the exact value that is sent: some frameworks append; charset=utf-8after your code runs.@body-sha256: base64 SHA-256 of the response body as the browser delivers it (sign the uncompressed body;HEADresponses and empty bodies hash the empty string).
-
The server signs the base with Ed25519 and sends
X-HSP-Signature: <base64 signature>andX-HSP-Created: <same time>.
The server must also:
- answer CORS preflights with
Access-Control-Allow-Headers: X-HSP-Nonceand expose the signature withAccess-Control-Expose-Headers: X-HSP-Signature, X-HSP-Created; - send
Cache-Control: no-storeon signed responses (a cached response carries the nonce of an earlier request and fails verification).
Test vector, for checking a server implementation only (never deploy
this key, its seed is public): Ed25519 seed 00 01 02 … 1f, public key
A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=, response body {"id":42}.
The base above signs to
lqUamjCnsQgg1W1/HZmLh9Ew25fyPhoF75EH32Kk/J/Var13xKU7vTTByb//BzsrZlB91P/P1RMy9I+JEMpjDw==.
Node.js server
import crypto from 'node:crypto';
// PKCS#8 Ed25519 private key (keep it out of source control).
const privateKey = crypto.createPrivateKey(process.env.HSP_PRIVATE_KEY_PEM);
export function signResponse({ method, authority, requestTarget, nonce, status, contentType, body }) {
const created = Date.now();
const base = [
'hsp-sig-v2',
`@method: ${method.toUpperCase()}`,
'@scheme: https',
`@authority: ${authority}`,
`@request-target: ${requestTarget}`,
`@status: ${status}`,
`@nonce: ${nonce}`,
`@created: ${created}`,
`content-type: ${(contentType ?? '').trim()}`,
`@body-sha256: ${crypto.createHash('sha256').update(body ?? '').digest('base64')}`,
].join('\n');
return {
'X-HSP-Signature': crypto.sign(null, Buffer.from(base, 'utf8'), privateKey).toString('base64'),
'X-HSP-Created': String(created),
'Cache-Control': 'no-store',
};
}
Dart server or tests
ResponseSigner is the reference implementation:
import 'dart:convert';
import 'package:http_security_pinning/http_security_pinning.dart';
Future<Map<String, String>> sign(List<int> seed32, String nonce) async {
final signer = await ResponseSigner.fromSeed(seed32);
return signer.sign(
method: 'GET',
url: Uri.parse('https://api.example.com/users/42'),
statusCode: 200,
nonce: nonce,
contentType: 'application/json',
bodyBytes: utf8.encode('{"id":42}'),
);
}
Debugging and badCertificateCallback
This section describes Android, iOS and desktop. On the web the callback has
the signature bool Function(Object error, String host, int port) and
receives the verification error; the same restrictions apply.
badCertificateCallback is notified when a server's chain matches no pin
(with a PresentedCertificate, which implements X509Certificate and also
exposes pem) and when a TLS handshake fails. By default its return value is
ignored and the request fails.
import 'package:http_security_pinning/http_security_pinning.dart';
final client = HttpSecurityPinningClient([
'PRIMARY0PIN000000000000000000000000000000AA=',
'BACKUP00PIN000000000000000000000000000000AA=',
])
..badCertificateCallback = (cert, host, port) {
print('Rejected certificate for $host:$port: ${cert.subject}');
return false;
};
To inspect traffic with a debugging proxy (Charles, Proxyman, mitmproxy) you can let the callback accept a failed certificate:
import 'package:flutter/foundation.dart';
import 'package:http_security_pinning/http_security_pinning.dart';
final client = HttpSecurityPinningClient(
[
'PRIMARY0PIN000000000000000000000000000000AA=',
'BACKUP00PIN000000000000000000000000000000AA=',
],
honorBadCertificateCallback: kDebugMode,
)..badCertificateCallback = (cert, host, port) => host == 'staging.example.com';
The bypass is deliberately limited:
- it is ignored in release builds,
- it applies to one request (the callback is asked again next time; for
unpinned hosts a certificate accepted by the callback stays accepted for
the keep-alive connection, as in plain
dart:io), - it never overrides a connection whose certificate matched a pin.
Error handling
Pinning and verification failures are CertificatePinningExceptions
(table below). Other errors keep their usual types: ArgumentError for
invalid configuration or URLs, StateError after close(),
RedirectException when maxRedirects is exceeded or a Location header is
invalid, and network errors such
as SocketException, HandshakeException, TimeoutException or
http.ClientException (including ResponseTooLargeException on the web).
| Exception | Meaning |
|---|---|
NoValidPinsFoundException |
The server's chain matches no pin. host, observedPins, presentedChain, leafOnly. |
CertificateFetchException |
The certificate probe failed (code, isRetryable). |
UnpinnedHostException |
The host has no pins and the policy does not allow unpinned hosts. |
InsecureConnectionException |
A non-HTTPS URL (or an HTTPS → HTTP redirect) to a pinned host. |
InvalidPinException |
A configured pin is not a valid base64 SHA-256 digest. |
CertificateFormatException |
A certificate could not be parsed. |
WebSecurityException (web) |
SignatureVerificationException, MissingSecurityHeaderException, MalformedSecurityHeaderException, ReplayAttackException. |
ResponseTooLargeException (web, an http.ClientException) |
The body exceeds maxResponseBytes. |
import 'package:http_security_pinning/http_security_pinning.dart';
Future<void> fetch(HttpSecurityPinningClient client) async {
try {
final request = await client.getUrl(Uri.parse('https://api.example.com/'));
await (await request.close()).drain<void>();
} on NoValidPinsFoundException catch (e) {
// Possible interception, or outdated pins.
print('Pin mismatch for ${e.host}; server presented ${e.observedPins}');
} on CertificateFetchException catch (e) {
print('Probe failed: ${e.message} (code ${e.code}, retryable: ${e.isRetryable})');
} on UnpinnedHostException catch (e) {
print('Host ${e.host} is not allowed');
} on InsecureConnectionException catch (e) {
print('Refused cleartext request to ${e.url}');
}
}
Configuration reference
HttpSecurityPinningClient(pins, ...) and .perHost(pinsByHost, ...)
| Parameter | Default | Description |
|---|---|---|
spkiHashes / pinsByHost |
required | Pins for every host, or per host pattern. Must not be empty. |
allowUnpinnedHosts (perHost) |
false |
true: hosts without pins use normal TLS without pinning. |
timeout |
10 s | Timeout of one certificate probe. Must be positive. |
retryCount |
3 | Extra probe attempts after a failure. |
retryDelay |
200 ms | Base delay; attempt n waits retryDelay * n. |
honorBadCertificateCallback |
false |
Let badCertificateCallback accept one failed request (debug/profile builds only). |
pinningService |
new, with the shared cache | Inject a custom fetcher or cache (tests). |
serverPublicKeys, serverPublicKeysByHost, timestampTolerance, allowUnverifiedResponses, maxResponseBytes |
– | Used on the web only; ignored on other platforms. |
UniversalSecurityClient.create(...)
| Parameter | Default | Description |
|---|---|---|
spkiPins |
– | Required on Android, iOS and desktop. |
serverPublicKeys |
– | Required on the web unless allowUnverifiedResponses. |
allowUnverifiedResponses |
false |
Web: let responses from hosts without keys pass unverified. |
timeout |
10 s | Native: probe timeout. Web: deadline for a verified response, body included. |
retryCount / retryDelay |
3 / 200 ms | Native: probe retries. Web: retries of idempotent requests after network errors. |
timestampTolerance |
5 min | Web: accepted clock difference, null to disable. |
maxResponseBytes |
10 MiB | Web: largest body buffered for verification. |
innerClient |
– | Web only (e.g. a BrowserClient). Must be null elsewhere. |
Best practices
These follow RFC 7469, the Android Network Security Configuration guide, Apple's guidance on identity pinning, and OWASP MASVS (MASVS-NETWORK-2).
- Ask whether you need pinning. Platform vendors recommend relying on the system trust store unless you have a specific threat (for example mis-issued certificates or user-installed CAs). Pinning adds an outage risk.
- Always ship at least two pins, one of them for a key that is not deployed yet and kept offline (see backup key). The package prints a debug warning when a host has a single pin.
- Pin keys you control. Do not pin third-party services (payment providers, OAuth, CDNs, analytics): they rotate keys without notice.
- Choose what to pin per platform. An intermediate CA pin survives leaf
renewals on Android and iOS but never matches on desktop, where only the
leaf is visible. Pinning the leaf key works everywhere but requires reusing
the key on renewal (ACME clients generate a new key by default; use for
example
certbot --reuse-key). Public certificate lifetimes are getting shorter (47 days by 2029), so plan for frequent renewals. - Have a recovery path. Ship pins with a remote configuration you can
update (fetched over a channel that does not depend on those pins), or a
kill switch, and keep the backup key's private half safe. Consider an
expiry date after which the app stops pinning, as Android's
pin-set expirationdoes, so that very old app versions cannot be bricked. - Test the failure. Run the app through mitmproxy or Charles with its CA installed on the device and check that pinned requests fail.
- Cover other network stacks (WebView, native SDKs) with Android
pin-set/ iOSNSPinnedDomainsif they talk to the same backend.
Testing
flutter test # Dart unit tests (VM), with real TLS servers on localhost
Native tests perform real TCP connections and TLS handshakes against local servers (expired, wrong-host, self-signed EC and untrusted-CA certificates, TLS 1.2 / 1.3, client-certificate servers, SNI, IPv6, servers that close, reset or never answer, CONNECT proxies that refuse, close or misbehave, and concurrent load), so they exercise the real probe code without depending on the Internet:
# Android (JUnit), from example/android after `flutter build apk --debug`
./gradlew :http_security_pinning:testDebugUnitTest :http_security_pinning:lintDebug
# iOS (XCTest, macOS), from example after `flutter build ios --config-only --simulator`
xcodebuild test -workspace ios/Runner.xcworkspace -scheme Runner \
-destination 'platform=iOS Simulator,name=iPhone 16'
dart run tool/check_readme_snippets.dart compiles every Dart snippet of this
README against the package.
The on-device tests in example/integration_test talk to public hosts
(github.com, badssl.com); they are meant for manual runs on a device
(cd example && flutter test integration_test) and are not part of CI.
License
BSD 3-Clause, see LICENSE. Versions 1.0.0 to 1.2.1 were published under the MIT License.
Libraries
- http_security_pinning
- A secure-by-default HTTP client for Flutter with TLS certificate pinning against Subject Public Key Info (SPKI) SHA-256 hashes on Android, iOS, Windows, macOS and Linux, and Ed25519 response-signature verification on Flutter Web (where browsers do not allow TLS pinning).