Skip to content

AwsWafIntegration.getToken() resolves with an empty value after ~10s instead of throwing after 2s

0

QUESTIONS

  1. Is the ~10 second wait expected? The documentation states a 2 second timeout.

  2. Is resolving with an empty value (instead of throwing) an intended outcome? If so, under what conditions does it happen?

  3. Is this the expected client-side signal when the silent challenge DECLINES to issue a token, as opposed to a transient failure? We would like to tell the two apart, but the SDK surface looks identical in both cases.

  4. Is there any client-observable way to distinguish them, without inspecting server-side WAF logs?

SETUP

We use the AWS WAF JavaScript integration (challenge.js), loaded with defer in <head>.

Our page origin differs from the protected API domain, so the aws-waf-token cookie is not attached automatically. Following the documented alternative, we call AwsWafIntegration.getToken() and send the value in the x-aws-waf-token header.

The page runs inside an embedded WebView (iOS WKWebView) provided by a third-party host application, over HTTPS.

EXPECTED BEHAVIOR

Per the documentation (How to use the integration getToken):

"Otherwise, the call retrieves a new token from the token provider, waiting for up to 2 seconds for the token acquisition workflow to complete before timing out. If the operation times out, it throws an error, which your calling code must handle."

So we expect either a token, or a thrown error after roughly 2 seconds.

OBSERVED BEHAVIOR

For a subset of clients, getToken() does NOT throw. It resolves after approximately 10 seconds with a falsy value (empty string or undefined).

Measured client-side with performance.now() around the awaited call:

  • Occurrences over 7 days : 564
  • elapsed_ms : tightly clustered at 10,000 - 10,008 ms
  • Exceptions thrown : 0 (our try/catch never fires for these)
  • window.AwsWafIntegration : present (script loaded successfully)
  • hasToken() after the call : false

The tight clustering around 10,000 ms suggests an internal limit rather than network variance. We observe the same ~10s pattern on a second, unrelated web property using the same integration, so it does not appear specific to the WebView.

Other clients on the same build acquire tokens normally in 0-5 ms when a valid token already exists.

Note on measurement: elapsed_ms is wall-clock time until the await resumes, so it includes any main-thread blocking. We consider that unlikely to explain these numbers given the tight 8 ms spread around 10,000 ms, but we have not yet isolated it with an event-loop-lag control.

asked a month ago67 views

1 Answer
1

Hi,

Your measurement methodology is solid and the clustering around 10,000 ms is the key signal — that's not network variance, and it's not the documented 2-second timeout. I can answer parts of your questions definitively from the public docs and from observed SDK behavior, but one piece requires speculation because it isn't documented. Let me split them clearly.

Q1 — Is the ~10 second wait expected?

No, and the 2-second timeout in the documentation is correct for the normal case. The getToken documentation states:

Otherwise, the call retrieves a new token from the token provider, waiting for up to 2 seconds for the token acquisition workflow to complete before timing out. If the operation times out, it throws an error, which your calling code must handle.

So the documented contract is: return immediately if a valid token exists, wait up to 2 seconds if acquisition is needed, throw on timeout. A 10-second wait is not normal behavior and is not described in the public documentation. The tight distribution at exactly 10,000 ms suggests an internal fallback or retry mechanism that isn't surfaced in the API contract, rather than anything you've misconfigured.

Q2 — Is resolving with an empty value (instead of throwing) an intended outcome?

This is not documented as an intended outcome. The published contract says getToken() either:

  • returns a token string (when successful, cached or newly acquired), or
  • throws an error (on timeout or failure).

Resolving the promise with a falsy value (empty string / undefined) is a third, undocumented state. Given that your try/catch never fires for these 564 occurrences, the SDK is choosing to resolve rather than reject, which breaks the documented behavior. Whether this is intentional (a design choice for a specific failure mode) or a bug (swallowed rejection / incorrect promise handling) is not stated in the public docs.

Q3 — Is this the expected client-side signal when the silent challenge declines to issue a token?

This is where I have to speculate, because AWS does not publish the conditions under which the silent challenge refuses to issue a token. What the docs do say:

  • The silent challenge runs client-side JavaScript (fingerprinting, environment checks, behavioral signals) to determine bot likelihood.
  • From the CAPTCHA and Challenge actions page, a request with a missing, invalid, or expired token is blocked. But that describes server-side WAF behavior when evaluating a request, not what causes token acquisition to fail client-side in the first place.
  • There's a StackOverflow post (2024) claiming "the silent challenge will always succeed as long as you can execute JavaScript", which would suggest acquisition failure is rare or environment-specific rather than a deliberate decline based on bot scoring.

However, your specific environment — iOS WKWebView inside a third-party host application — is a known edge case. WebViews, especially embedded ones, can differ from Safari in critical ways:

  • User-Agent string may identify as a non-browser or custom app.
  • JavaScript APIs may be restricted or shimmed (especially sensitive ones like navigator properties, canvas fingerprinting, or Web Crypto).
  • Cookie/storage isolation may differ from a normal browsing context.
  • Third-party script restrictions may apply if the host app has content security policies.

Any of these could cause the silent challenge to complete its execution (no throw) but refuse to issue a token (empty return value), treating the environment as insufficiently trustworthy. That would explain why the promise resolves rather than rejects — the SDK considers "challenge ran but declined to issue a token" a successful execution, just with a null result.

The 10-second timeout in this scenario could be an internal retry loop: the SDK attempts the challenge, waits for a response from an AWS endpoint, retries on silence/ambiguity, and gives up after 10 seconds, returning empty rather than throwing. This is pure inference, since the SDK is minified and not open-source, but it fits the observed timing.

Q4 — Is there any client-observable way to distinguish "declined to issue" from "transient failure"?

Not from the published API surface, and that's a real gap. The SDK returns the same observable state (empty string, no throw, hasToken() === false) for both:

  • "The challenge decided this environment is suspicious and will not issue a token" (policy decision), and
  • "The challenge timed out / network failed / internal error occurred" (transient failure).

You cannot distinguish them client-side without additional instrumentation. Here's what you can do to narrow it down:

1. Check browser console for SDK-internal errors or warnings

The challenge.js SDK may log to the console on certain failure modes even if it doesn't throw. Look for:

  • Network errors to AWS WAF token endpoints (check the Network tab for failed requests to *.awswaf.com or similar).
  • Console warnings about unsupported features, missing APIs, or environment issues.

Run this in an affected WebView:

// Capture console output before calling getToken
const originalError = console.error;
const originalWarn = console.warn;
const logs = [];
console.error = (...args) => { logs.push(['error', ...args]); originalError(...args); };
console.warn = (...args) => { logs.push(['warn', ...args]); originalWarn(...args); };

const token = await AwsWafIntegration.getToken();
console.log('Token:', token, 'Logs:', logs);

console.error = originalError;
console.warn = originalWarn;

If the SDK is logging anything meaningful (like "environment not supported" or "token acquisition declined"), that gives you a signal to fork on.

2. Test in a standard mobile Safari tab on the same device

Run your page in Safari directly on iOS (not the embedded WebView) and compare:

  • If getToken() succeeds in Safari but fails in the WKWebView → the issue is WKWebView-specific (host app restrictions, different User-Agent, or API availability).
  • If it also fails in Safari → the issue is either account/WAF-rule configuration or the iOS network/environment more broadly.

This isolates whether the WebView environment is the variable.

3. Inspect the User-Agent and navigator object

Embedded WebViews often have non-standard User-Agent strings that may signal "not a real browser" to the challenge. Log this from the WebView:

console.log('UA:', navigator.userAgent);
console.log('Platform:', navigator.platform);
console.log('Vendor:', navigator.vendor);
console.log('Has crypto:', !!window.crypto?.subtle);

Compare it to Safari's output. If the User-Agent is obviously custom (e.g., contains the host app's name), or if window.crypto.subtle is missing (the challenge likely uses Web Crypto for signing), that's a strong candidate cause.

4. Server-side: check WAF logs for these clients

You mentioned you'd like to avoid inspecting server-side logs, but honestly this is the only definitive way to distinguish "no token was issued" from "token was issued but lost/unusable." Check the WAF logs for requests from these clients:

  • If the request arrives with no token at all (or an empty x-aws-waf-token header) → the client-side acquisition genuinely failed or was declined.
  • If the request arrives with a token that WAF evaluates as invalid (wrong signature, expired, wrong ACL) → the client actually got something, and your client-side "empty value" logging may be capturing a transient state before the token was fully written.

From the troubleshooting guide, failed token acquisition typically results in a WAF rule action of Block (if the rule requires a valid token), so the HTTP status and WAF action in the logs tell you whether the issue is "no token" vs "bad token."

What I think is happening (hypothesis)

Combining the evidence:

  1. The 10-second timeout is not documented but is too consistent to be accidental — it's likely an internal retry/fallback mechanism in the SDK.
  2. The empty return value instead of a thrown error suggests the SDK treats "challenge completed but declined to issue a token" as a non-exceptional outcome.
  3. The WKWebView environment is the likely trigger. Embedded WebViews lack the full browser context that the challenge expects (stable User-Agent, full Web Crypto, no app-imposed restrictions), so the challenge may refuse to issue a token as a security policy.

This would be consistent with AWS WAF's design goal: ensure tokens are only issued to environments it trusts. A WebView inside a third-party app is harder to verify than Safari, so the challenge takes the conservative position of completing (no throw) but issuing nothing (empty token).

What you should do

Immediate: add client-side telemetry to distinguish the scenarios

Since the SDK doesn't surface the distinction, track correlated signals:

const start = performance.now();
const token = await AwsWafIntegration.getToken();
const elapsed = performance.now() - start;

logToAnalytics({
  token_acquired: !!token,
  elapsed_ms: elapsed,
  has_token_after: AwsWafIntegration.hasToken(),
  user_agent: navigator.userAgent,
  is_webview: /WebView|wv/.test(navigator.userAgent), // heuristic
  crypto_available: !!window.crypto?.subtle
});

Correlate elapsed_ms ~= 10000 with is_webview: true and crypto_available: false (or other missing APIs) to confirm the hypothesis.

Medium term: test with explicit apiDomain configuration

If your setup allows, explicitly configure the challenge script's apiDomain to point to your actual protected domain, rather than relying on automatic discovery. From the troubleshooting guide, misconfigured domain resolution can cause token acquisition to silently fail. The script may be trying to call https://your-origin.com/token-endpoint but the WebView's network stack (or the host app's proxy/firewall) is blocking it.

Long term: consider fallback UX or token pre-acquisition

If a meaningful fraction of your WebView users cannot acquire tokens, your options are:

  1. Detect the WebView and show a "please open in Safari" prompt — not ideal UX, but if the host app doesn't give you a viable environment, you can't force it.
  2. Relax WAF rules for known-good WebView User-Agents — if the host app is identifiable and trustworthy (e.g., a partner's app), you can configure a WAF rule to allow requests without a token from that specific User-Agent. This is less secure but may be pragmatic.
  3. Pre-acquire the token in a standard browser context (if your flow allows) and somehow pass it to the WebView — fragile and likely violates token scoping, so probably not viable.

Report to AWS Support

The behavior you're observing — resolving with an empty value after 10 seconds, instead of throwing after 2 seconds — contradicts the published API contract. Open a case with:

  • The exact SDK version (check the script URL / version string),
  • The observed behavior (564 cases, all 10,000 ms, all empty, zero throws),
  • Your environment (WKWebView, third-party host app, iOS version),
  • Whether it reproduces in Safari on the same device.

Ask explicitly: "Is this a bug (incorrect promise handling), or is this an undocumented outcome for environments that fail the silent challenge? If the latter, can you document the conditions and provide a way to distinguish 'declined' from 'transient failure'?"

That's the feedback that would actually improve the SDK's contract clarity for the next person.

Summary

Your questionAnswer
Is 10s expected?No. Documented timeout is 2s. 10s is an undocumented internal limit.
Is empty value instead of throw intended?Not documented. It contradicts the published contract.
Is this the signal for "challenge declined to issue"?Likely, especially in restricted environments like WKWebView, but not confirmed in public docs.
Client-observable way to distinguish decline from transient failure?No. The SDK returns identical state for both. Instrument User-Agent, crypto availability, and correlate with elapsed time. Check WAF logs server-side to see if any token arrived.

The core issue is that the SDK's observable behavior in your environment (10s, empty return, no throw) is not described in the published API contract. Instrument client-side to capture the environment variables, test in Safari to isolate the WebView as the cause, and escalate to AWS Support for clarity on whether this is a bug or an undocumented "environment not trusted" outcome.

AWS

answered a month ago

  • Thanks for the detailed answer. One note: we see the same pattern on a non-WebView browser property too, so the WebView doesn't seem to be the trigger. Most of these turn out to be bots on our side, so it may just be the challenge declining to issue a token.

You are not logged in. Log in to post an answer.

A good answer clearly answers the question and provides constructive feedback and encourages professional growth in the question asker.