August 11, 2026
Flutter WebView and Deep Link Security: The Doors You Left Open
Flutter Under the Hood, Part 7 of 9: WebView bridges, deep links and exported components. The attack surface that has nothing to do with…

By Abdulrahman Mohamed
12 min read
- 1 Flutter Under the Hood, Part 7 of 9: WebView bridges, deep links and exported components. The attack surface that has nothing to do with your Dart code
- 2 Part 0 — The mental model: MASVS, and the difference between a control and a boundary
- 3 Part 1 — The WebView is an RPC endpoint you handed to a stranger
- 4 1.1 The attack
- 5 1.2 The hardened configuration
Flutter Under the Hood, Part 7 of 9: WebView bridges, deep links and exported components. The attack surface that has nothing to do with your Dart code
Hardening & High-Speed Architecture
We obfuscate the binary, we pin the certificates, we detect root. The app is hardened.
You hardened the vault door. This article is about the side entrances.
Articles 1 through 6 attacked the app as a binary: reverse it, hook it, MITM it, leak it, regress it. Every one of those attacks starts with an adversary doing work. The vulnerabilities in this article are different, and worse: the attacker does not reverse anything. They send your app a URL.
An addJavaScriptChannel call is a remote-procedure-call endpoint for whoever controls the page you loaded. An exported activity is a function any app on the device can invoke. A deep link is untrusted user input that arrives pre-authenticated by the fact that your own app opened it. None of these are exotic. All of them are in the app you shipped last month, and none of them care that you passed --obfuscate.
Let's map the surface properly, with the industry's taxonomy rather than ad-hoc labels.
▶ Run the proof. Every claim ships as runnable, tested code in the companion app, flutter_hardening_poc, which is
flutter analyzeclean andflutter testgreen. This article: the Attack surface screen (lib/screens/attack_surface_screen.dart) runs the deep-link and WebView allow-list live against hostile URLs. The Hardened WebView screen (lib/screens/webview_screen.dart) is a realwebview_flutter4.14.1 WebView with JavaScript disabled, deny-by-default navigation andsetAllowFileAccess(false). The guard both share islib/services/link_guard.dart, covered bytest/link_guard_test.dart. Touch filtering is wired inandroid/.../MainActivity.kt.
Part 0 — The mental model: MASVS, and the difference between a control and a boundary
Six articles in, the main idea of the series has not changed: every client-side control is bypassable, so raise cost, get signal, and enforce the real decision on the server. What changes here is where you look.
Up to now we audited things attackers must break. Now we audit things attackers can simply call. That needs a map, and the industry already has one: the OWASP Mobile Application Security Verification Standard (MASVS), currently v2, which sorts every mobile control into eight groups:
OWASP MASVS v2 — the eight control groups
(this article lives almost entirely in PLATFORM)
┌────────────────┬──────────────────────────────────────┐
│ MASVS-STORAGE │ what you persist, and where │
│ MASVS-CRYPTO │ keys, randomness, algorithms │
│ MASVS-AUTH │ who you are, who says so │
│ MASVS-NETWORK │ the wire ← Articles 1–2 │
│ MASVS-PLATFORM │ IPC, WebViews, UI ← YOU ARE HERE │
│ MASVS-CODE │ inputs, deps, hygiene │
│ MASVS-RESILIENCE│ anti-tamper, attestation ← Art. 1 │
│ MASVS-PRIVACY │ what leaks about the user │
└────────────────┴──────────────────────────────────────┘OWASP MASVS v2 — the eight control groups
(this article lives almost entirely in PLATFORM)
┌────────────────┬──────────────────────────────────────┐
│ MASVS-STORAGE │ what you persist, and where │
│ MASVS-CRYPTO │ keys, randomness, algorithms │
│ MASVS-AUTH │ who you are, who says so │
│ MASVS-NETWORK │ the wire ← Articles 1–2 │
│ MASVS-PLATFORM │ IPC, WebViews, UI ← YOU ARE HERE │
│ MASVS-CODE │ inputs, deps, hygiene │
│ MASVS-RESILIENCE│ anti-tamper, attestation ← Art. 1 │
│ MASVS-PRIVACY │ what leaks about the user │
└────────────────┴──────────────────────────────────────┘From here on, every finding in this series carries a MASVS group and CWE id, the same tags the companion audit skill emits, so a finding in an article and a finding in a scan report are the same object.
And one distinction does all the work in this article:
A control is something you configure (a navigation delegate, an allow-list,
exported="false"). A boundary is a place an adversary genuinely cannot reach. Every control below is worth shipping. None of them is a boundary. The boundary is still your server.
Part 1 — The WebView is an RPC endpoint you handed to a stranger
MASVS-PLATFORM · CWE-749 (WEB-JS-CHANNEL)
1.1 The attack
You embedded a WebView for a help centre, a payment step, or a partner page. To make it talk to Flutter, you added a channel:
// The vulnerability, in four lines.
controller
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('AppBridge', onMessageReceived: (msg) {
// Whatever this does, the WEB PAGE decides when and with what.
_router.go(msg.message);
})
..loadRequest(Uri.parse(partnerUrl)); // ← attacker-influenced?// The vulnerability, in four lines.
controller
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('AppBridge', onMessageReceived: (msg) {
// Whatever this does, the WEB PAGE decides when and with what.
_router.go(msg.message);
})
..loadRequest(Uri.parse(partnerUrl)); // ← attacker-influenced?AppBridge is now a global JavaScript object on every page that WebView loads. Any script running in that WebView can call it: the page you trusted, an ad iframe inside it, a stored-XSS payload in a partner's CMS, or a redirect you did not anticipate.
AppBridge.postMessage("/settings/delete-account");AppBridge.postMessage("/settings/delete-account");The channel does not know who called it. It has no origin, no caller identity, no authentication. You wrote a native API and published it to the open web with no auth. That is exactly what CWE-749 (Exposed Dangerous Method or Function) describes, and it is the same class of bug that made Android's legacy addJavascriptInterface infamous.
The severity is set by one question: can an attacker influence what that WebView loads? If partnerUrl comes from a deep link, a push payload, a server response, or any redirect chain you do not control, the answer is yes, and this is critical.
1.2 The hardened configuration
Current webview_flutter (4.14.1 at time of writing) puts the whole configuration on WebViewController:
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
final controller = WebViewController()
// 1. JavaScript OFF unless the page genuinely needs it.
..setJavaScriptMode(JavaScriptMode.disabled)
// 2. Navigation allow-list - deny by default.
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) => LinkGuard.isAllowedWebUrl(request.url)
? NavigationDecision.navigate
: NavigationDecision.prevent,
),
)
// 4. Validate BEFORE loading - see the reality check below; the delegate
// does not get a say in programmatic loads.
..loadRequest(Uri.parse(LinkGuard.isAllowedWebUrl(startUrl)
? startUrl
: 'https://help.example.com/faq'));
// 3. Android-specific: kill file access explicitly.
if (controller.platform case final AndroidWebViewController android) {
await android.setAllowFileAccess(false);
await android.setAllowContentAccess(false);
}import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
final controller = WebViewController()
// 1. JavaScript OFF unless the page genuinely needs it.
..setJavaScriptMode(JavaScriptMode.disabled)
// 2. Navigation allow-list - deny by default.
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) => LinkGuard.isAllowedWebUrl(request.url)
? NavigationDecision.navigate
: NavigationDecision.prevent,
),
)
// 4. Validate BEFORE loading - see the reality check below; the delegate
// does not get a say in programmatic loads.
..loadRequest(Uri.parse(LinkGuard.isAllowedWebUrl(startUrl)
? startUrl
: 'https://help.example.com/faq'));
// 3. Android-specific: kill file access explicitly.
if (controller.platform case final AndroidWebViewController android) {
await android.setAllowFileAccess(false);
await android.setAllowContentAccess(false);
}Three rules, in priority order:
- Do not add a channel you do not need, and never add one to a WebView that loads third-party content. If you must have both, treat every message as hostile input: an allow-listed command enum, never a route string, never a raw
eval-able payload. - Allow-list navigation, deny by default. Return
NavigationDecision.preventfor anything off-list. - Never let an external input choose the URL. If a deep link or server response supplies it, validate it against a host allow-list before it reaches
loadRequest(Part 2'sLinkGuarddoes exactly this).
Reality check, and this one surprised me while building the companion app._ Two limits, and the second is the dangerous one._
(a)
onNavigationRequestis a navigation hook, not a network firewall. It does not gate the subresources a page pulls. An allow-listed page containing<script src="https://evil.example/x.js">or issuing afetch()still loads that content, and that script can call your channel. The delegate stops the WebView from going somewhere hostile. It does not stop an allowed page from being hostile.
(b) On Android,
onNavigationRequestdoes not fire forloadRequest()at all. Programmatic loads bypass the delegate, per flutter#152168. I hit this live: the PoC's WebView logs every delegate verdict, and callingloadRequestwith a deliberately hostile host produced no verdict line whatsoever (see the screenshot, which shows only the file-access line and subresource errors). The delegate never saw it.
Read (b) carefully, because it inverts the usual advice. The allow-list protects in-page navigation, meaning links the user or the page follows. It does not protect you from your own code loading an attacker-supplied URL. So rule 3 is not a nice-to-have layered behind rule 2. It is the only thing standing between a malicious deep-link parameter and your WebView. Validate before
loadRequest, not inside the delegate.
1.3 File access: know your defaults
MASVS-PLATFORM · CWE-200 (WEB-FILE-ACCESS)
Android's own guidance is blunt: enabling file access can allow malicious intents and WebView requests with a file:// context to access arbitrary local files, including WebView cookies and app private data. The defaults, per Google:
setAllowFileAccess()defaults totrueon API ≤ 29, andfalseon API 30+.setAllowFileAccessFromFileURLs()defaults tofalse(API 16+).setAllowUniversalAccessFromFileURLs()defaults tofalse(API 16+).
So if your minSdk is below 30, file access is on unless you turn it off, which is why the snippet above calls setAllowFileAccess(false) explicitly rather than trusting the default.
Reality check.
webview_flutter_android'sAndroidWebViewControllerexposessetAllowFileAccessandsetAllowContentAccess, but notallowFileAccessFromFileURLsorallowUniversalAccessFromFileURLs. Those two keep their safefalsedefaults, so you are fine by default. You just cannot assert them from Dart. If you need to serve local content, do not reach forfile://at all. UseWebViewAssetLoader(Android's recommended replacement), which serves your assets over anhttps://appassets.androidplatform.net/origin and keeps same-origin rules intact.
Part 2 — Deep links: untrusted input wearing your app's badge
MASVS-PLATFORM · CWE-926 (IPC-EXPORTED)
2.1 The attack
Your router handles myapp://reset-password?token=…&user=…. It looks internal. It is not: a deep link is a message from an arbitrary process or web page, and your router is its entry point. Worse, developers reflexively trust it, because the app opened it.
Two concrete failure modes:
- Parameter trust.
myapp://transfer?to=attacker&amount=5000. If the handler acts on those values because the user must have tapped it, a malicious app or a web page just moved money by firing an intent. - Intent redirection. Your component receives an
Intent(or a URL) in an extra and forwards it. Google's definition: An intent redirection occurs when an attacker can partly or fully control the contents of an intent used to launch a new component in the context of a vulnerable app, which lets the attacker reach your non-exported components through your app's own privileges.
2.2 The one that is mostly a false positive, and the ones that are not
rg -n 'android:exported="true"' android/rg -n 'android:exported="true"' android/Since Android 12 (API 31), any component with an intent filter must declare android:exported explicitly, because the implicit default is gone and the manifest merger fails without it. So this grep lights up on every app.
Your launcher MainActivity is supposed to be exported="true". That is not a finding. The launcher is how the OS starts your app. What matters is everything else: a service, receiver, provider or secondary activity exported without a permission guard is a function any installed app can call.
<!-- Expected: the launcher. Not a finding. -->
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Finding: anything else exported without a guard. -->
<service android:name=".SyncService" android:exported="false"/><!-- Expected: the launcher. Not a finding. -->
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Finding: anything else exported without a guard. -->
<service android:name=".SyncService" android:exported="false"/>2.3 Custom schemes are not yours
Here is the fact that reframes deep-link security:
myapp://belongs to whoever declares it. Any app on the device can register the same custom scheme. Android shows a disambiguation dialog, and the user picks. There is no ownership, no verification, no exclusivity. A malicious app that registers your scheme can receive links intended for you, including any token in the query string.
The fix is Android App Links (and iOS Universal Links): HTTPS links cryptographically bound to a domain you control. Add android:autoVerify="true":
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="example.com" />
<data android:scheme="https" />
</intent-filter><intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="example.com" />
<data android:scheme="https" />
</intent-filter>…then host the Digital Asset Links file at https://example.com/.well-known/assetlinks.json, over HTTPS with no redirects:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.deeplink_cookbook",
"sha256_cert_fingerprints":
["FF:2A:CF:7B:DD:CC:F1:03:3E:E8:B2:27:7C:A2:E3:3C:DE:13:DB:AC:8E:EB:3A:B9:72:A1:0E:26:8A:F5:EC:AF"]
}
}][{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.deeplink_cookbook",
"sha256_cert_fingerprints":
["FF:2A:CF:7B:DD:CC:F1:03:3E:E8:B2:27:7C:A2:E3:3C:DE:13:DB:AC:8E:EB:3A:B9:72:A1:0E:26:8A:F5:EC:AF"]
}
}]Now the OS verifies the domain–app binding against your signing certificate before routing.
Reality check.
autoVerifyis a routing guarantee, not an authorization one. It ensures links to your domain open your app instead of an impostor's. It does absolutely nothing about the link's contents.https://example.com/transfer?to=attackeris still attacker-authored input, and the fingerprint must match your release signing cert (Play App Signing rewrites it, so pull the fingerprint from the Play Console or verification silently fails and your links quietly fall back to the browser). Verify with the DevTools deep link validator.
2.4 The guard: validate at the edge, allow-list only
Treat every inbound URL, deep link and WebView navigation alike, as hostile until it passes one chokepoint:
/// One validation chokepoint for every URL that enters the app from
/// outside: deep links, push payloads, server-supplied links, WebView
/// navigations. Deny by default.
class LinkGuard {
static const _allowedHosts = {'example.com', 'help.example.com'};
static const _allowedPaths = {'/home', '/orders', '/faq', '/reset-password'};
static bool isAllowedWebUrl(String raw) {
final uri = Uri.tryParse(raw);
if (uri == null) return false;
// 1. Scheme allow-list. Blocks javascript:, file:, data:, intent:, content:
if (uri.scheme != 'https') return false;
// 2. Host allow-list - exact match. NEVER endsWith(): "evil-example.com"
// and "example.com.attacker.tld" both pass a naive suffix check.
if (!_allowedHosts.contains(uri.host.toLowerCase())) return false;
// 3. No embedded credentials (https://example.com@evil.tld/ tricks).
if (uri.userInfo.isNotEmpty) return false;
return true;
}
/// Deep links additionally get a route allow-list - the router should
/// never receive a path the app didn't explicitly publish.
static bool isAllowedDeepLink(String raw) {
final uri = Uri.tryParse(raw);
if (uri == null) return false;
if (!_allowedPaths.contains(uri.path)) return false;
return uri.scheme == 'https' ? isAllowedWebUrl(raw) : uri.scheme == 'myapp';
}
}/// One validation chokepoint for every URL that enters the app from
/// outside: deep links, push payloads, server-supplied links, WebView
/// navigations. Deny by default.
class LinkGuard {
static const _allowedHosts = {'example.com', 'help.example.com'};
static const _allowedPaths = {'/home', '/orders', '/faq', '/reset-password'};
static bool isAllowedWebUrl(String raw) {
final uri = Uri.tryParse(raw);
if (uri == null) return false;
// 1. Scheme allow-list. Blocks javascript:, file:, data:, intent:, content:
if (uri.scheme != 'https') return false;
// 2. Host allow-list - exact match. NEVER endsWith(): "evil-example.com"
// and "example.com.attacker.tld" both pass a naive suffix check.
if (!_allowedHosts.contains(uri.host.toLowerCase())) return false;
// 3. No embedded credentials (https://example.com@evil.tld/ tricks).
if (uri.userInfo.isNotEmpty) return false;
return true;
}
/// Deep links additionally get a route allow-list - the router should
/// never receive a path the app didn't explicitly publish.
static bool isAllowedDeepLink(String raw) {
final uri = Uri.tryParse(raw);
if (uri == null) return false;
if (!_allowedPaths.contains(uri.path)) return false;
return uri.scheme == 'https' ? isAllowedWebUrl(raw) : uri.scheme == 'myapp';
}
}Then the rule that makes this matter: a deep link may select a destination, and it may never carry an authorization. ?token=… in a link is a credential in a URL, so it lands in browser history, referrer headers, and the logs of every hop. The link says go to the password-reset screen. The server decides whether this session may reset that password. Same thesis as Article 1, new door.
Part 3 — The same doors, on iOS
Everything above named an Android API, which makes it easy to assume iOS is safe by omission. It is not. The doors are the same, the names differ, and one Android concept is missing entirely in a way that concentrates the risk rather than removing it.
3.1 The WebView bridge is WKScriptMessageHandler
webview_flutter is WKWebView under the hood on iOS, and addJavaScriptChannel becomes a script message handler:
userContentController.add(self, name: "AppBridge")userContentController.add(self, name: "AppBridge")Any page in that WebView then calls it with:
window.webkit.messageHandlers.AppBridge.postMessage("/settings/delete-account");window.webkit.messageHandlers.AppBridge.postMessage("/settings/delete-account");Identical exposure, identical CWE-749 and, the part that matters, the same Dart line creates it on both platforms. One addJavaScriptChannel call is two native bridges. Everything in Part 1 applies unchanged.
3.2 iOS has no exported, and that is not good news
There is no android:exported on iOS, so the audit every exported component grep has nothing to match. That does not shrink the attack surface. It means the entire inbound surface is URL schemes and links.
<!-- Info.plist — the iOS twin of an exported activity -->
<key>CFBundleURLTypes</key>
<array><dict>
<key>CFBundleURLSchemes</key><array><string>myapp</string></array>
</dict></array><!-- Info.plist — the iOS twin of an exported activity -->
<key>CFBundleURLTypes</key>
<array><dict>
<key>CFBundleURLSchemes</key><array><string>myapp</string></array>
</dict></array>Any app on the device can register myapp:// too. Same rule as Part 2.3: a custom scheme is a claim, not a property. Everything arriving through one is untrusted input, and nothing that arrives through one may carry authorization.
3.3 Universal Links: the twin of autoVerify
The ownership-verified path is Universal Links, backed by an apple-app-site-association file:
{ "applinks": { "details": [
{ "appID": "TEAMID.com.example.app", "paths": ["/orders/*", "/reset-password"] }
]}}{ "applinks": { "details": [
{ "appID": "TEAMID.com.example.app", "paths": ["/orders/*", "/reset-password"] }
]}}Hosted at https://example.com/.well-known/apple-app-site-association, over HTTPS, with no redirects and no .json extension, served with a direct 200. Then add the entitlement:
<key>com.apple.developer.associated-domains</key>
<array><string>applinks:example.com</string></array><key>com.apple.developer.associated-domains</key>
<array><string>applinks:example.com</string></array>Reality check. Two asymmetries with Android worth planning around. (a) The AASA file is fetched by Apple's CDN and applied at install or update time, not on every launch, so fixing a broken AASA does not repair already-installed apps until they update. Android's
autoVerifyre-verification is more forgiving. (b)appIDisTEAM_ID.BUNDLE_ID, so it changes if the team changes, the same class of trap as using a debug signing fingerprint inassetlinks.json. Verify with the DevTools deep link validator, which checks both platforms.
Part 4 — Tapjacking: the overlay you cannot see
MASVS-PLATFORM · CWE-1021 (PLT-TAPJACK)
A malicious app draws a transparent overlay over your Confirm transfer button. The user taps what looks like Dismiss, and the touch lands on Confirm. Android calls this tapjacking, which is clickjacking for apps.
Android's mitigation is a view flag that discards touches arriving while the window is obscured:
class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setFlags(FLAG_SECURE, FLAG_SECURE) // Article 1
window.decorView.filterTouchesWhenObscured = true // this article
}
}class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setFlags(FLAG_SECURE, FLAG_SECURE) // Article 1
window.decorView.filterTouchesWhenObscured = true // this article
}
}Flutter renders into a single native view, so you set this once on the decor view rather than per-widget. flutter#40422, the request for a framework-level API, is still open, so there is no Dart-side equivalent.
Reality check: this one is mostly already fixed for you._ Per Google,_ Android S (12, SDK 31) and higher prevent full occlusion attacks by default, by blocking touch events from non-trusted overlays from another UID. So on modern Android the flag is defence-in-depth for older devices. It also does not stop partial occlusion, and it does not stop abuse of accessibility services. Ship it on sensitive confirmation screens, rate it Low, and do not let it distract from Part 1.
The honest scorecard
No JS channel on untrusted content
- Stops: the whole CWE-749 class.
- Does not stop: nothing. This is removal, not mitigation.
- Real value: the only actual fix. Everything else is compensating.
NavigationDecision.prevent allow-list
- Stops: in-page navigation going off-domain.
- Does not stop: subresources,
fetchand XHR, andloadRequestentirely, on Android. - Real value: real, but narrower than it looks.
Validating the URL at the call site
- Stops: your own code loading an attacker-supplied URL.
- Does not stop: nothing. This is the check the delegate cannot do for you.
- Real value: the one that actually covers deep-link→WebView.
setAllowFileAccess(false)
- Stops:
file://reads of app-private data. - Does not stop: anything the page loads over https.
- Real value: closes a default-on hole below API 30.
exported="false" plus intent validation
- Stops: other apps invoking your components.
- Does not stop: links the user genuinely taps.
- Real value: removes the free-call surface.
App Links autoVerify plus assetlinks.json
- Stops: scheme hijacking by an impostor app.
- Does not stop: malicious content in a legitimate link.
- Real value: routing integrity only.
Universal Links plus AASA (the iOS twin)
- Stops: the same hijacking on iOS.
- Does not stop: the same thing. Content is still untrusted.
- Real value: the same value, and note the install-time fetch.
filterTouchesWhenObscured
- Stops: full-occlusion overlays before Android 12.
- Does not stop: partial occlusion, or accessibility abuse.
- Real value: low-cost, and mostly redundant on modern Android.
Server-side authorization of the action
- Stops: every one of the above being bypassed.
- Does not stop: nothing worth listing.
- Real value: the boundary. Everything else is a control.
Read that last entry against the other eight: each control removes a free attack, and none of them decides anything. A deep link, a WebView message and an intent are all just requests, and requests get authorized on the server, or they do not get authorized at all.
Coming next in the series
Article 8: Flutter Secure Random, Logged Tokens and Unverified JWTs The findings that never make a demo video and empty accounts anyway: Random() where you needed Random.secure(), an access token sitting in logcat, a password on the shared clipboard, android:debuggable in a release build, string-interpolated SQL, and a JWT the client validated by decoding it.