Securing WebViews with Chrome Custom Tabs


Updated on April 09, 2020

Plaid empowers innovators in the fintech space by providing them with access to financial data via a uniform API.  In order to help end users connect their banking data to fintech apps, Plaid developed Link, a drop-in module that handles credential validation, multi-factor authentication, and error handling for every supported bank.

Android developers previously had to open Link URLs in WebViews.  Every app that wanted to use Link required a lot of developer effort and, without standardization, there were bound to be bugs in some of the implementations. In order to improve the developer experience, we recently released an SDK making the WebView easy to integrate into any app with a few lines of code.

Additionally, WebViews are not completely secure for end users who input sensitive information into the Link flow. In order to make our SDK as secure as possible, we've implemented it using Chrome custom tabs instead of Android WebViews. In this article we explain why we've made that decision and how we've overcome the technical issues we encountered along the way.

Evaluating Our Options

As an SDK, our code runs within other developers’ applications, and, in turn, their application processes.  In order to run the Link web app in a WebView, Javascript has to be enabled. This opens the door for other apps to run malicious code, such as registering callbacks that try to intercept usernames and passwords. Additionally, a malicious app could open another web page that mimics the Link flow in a phishing attempt.

When looking for a solution that is easy to integrate with for developers, intuitive for end users, and secure, we evaluated several options:

  1. building a native link flow: Because native Link flows would also run in another app's process, savvy developers could use reflection to find our input EditTexts and register callbacks in a manner similar to Javascript in a WebView.
  2. building a separate authenticator app: This would provide a native sandboxed experience and would be an ideal experience for end-users; however, many users would not want to download an extra app from the Play Store.  This means we would need a fallback solution for users who refuse to download the app.
  3. opening Link in a separate browser window: This would be a sandboxed, secure solution. Almost all users have a browser installed, but the context switching from an app to a browser would introduce a noticeable delay, especially on low-end devices.
  4. using Chrome Custom Tabs: This is the solution we chose, as it had none of the drawbacks mentioned above.

Our Choice: Chrome Custom Tabs

Chrome custom tabs (CCT) is a part of the Chrome browser that integrates with the Android framework to allow apps to open websites in a lightweight process.  CCT opens faster than a browser and, if preloaded via its warm-up call, is potentially even faster than a WebView. While it still runs Javascript, it is in its own process, which prevents apps from running malicious code. Furthermore, the CCT UI provides an action bar which shows the URL of the page being loaded, along with an SSL verification lock icon for secure pages. This reassures users that the correct page is being shown.

While not all users have Chrome installed, the vast majority do. For those who do not, we are using the browser fallback method described above (option 3); other than adding one line of code, we get the fallback for free from CCT.  As noted before, the browser fallback is not the ideal user experience due to its latency, but it does maintain the high level of security that Plaid requires.  

As we developed the CCT solution, we ran into and addressed several complications:

  • Getting event data
  • Retrieving the final result
  • Controlling the CCT activity & process

Getting Event Data

When a user navigates between screens in Link, a redirect occurs and data is provided to developers in the URL parameters.  

This redirect information is valuable for developers, as it helps them understand user behavior.  With CCT running in its own process and not providing a redirect callback, this information would normally be inaccessible, which would make our secure SDK less functional for developers than their custom WebView implementations.

To provide this information from CCT, we instead recorded the redirect events on the server in a Redis datastore.  When the SDK opens Link, it makes a bootstrap call to our servers, which provide a per-user channel ID chosen by us and a secret key.  The SDK then creates an app-scoped worker object, which polls the server using an RX interval stream.  On each polling call we provide the server with the channel ID, secret key, and the last event's UUID (or null) to get the latest events.

Observable.interval(interval, TimeUnit.SECONDS)
  .subscribeOn(Schedulers.computation())
  .observeOn(AndroidSchedulers.mainThread())
  .flatMapSingle(makeNetworkCall())
  .subscribe(
      {
          // Handle messages
      },
      {
          // Handle errors
      })

The Android framework may kill any process, including the app using Plaid's SDK. Since the worker object is tied to the application, this would result in the worker being stopped. If the user continued the flow, (either successfully or unsuccessfully) the SDK would make a final call to the channel and get the remaining events.  Events would only be lost if the user aborted the flow and force killed the app.

Retrieving the Final Result

Similar to passing event data to clients, Link uses the URL to signal that the user has completed the flow. The relevant URL includes necessary data, such as the public key or an error code.

Since we can't access the URL with CCT, we stored the final result in Redis with the same channel ID.  While this means that our polling worker would know when Link has finished, there would be no guarantee that the worker would still be alive.  Even if it were alive, the user may have to wait until the next polling call for the result to be delivered, which could be a long couple of seconds.

To ensure a result is delivered in a timely manner, we use a deep link to reopen the SDK.  The deep link is constructed using the application's app ID, which must be associated with the client secret and whitelisted on the developer dashboard. This, plus the fact that only one app on a device can have the same app ID, ensures no other apps intercept the redirect.  The Link web app then builds an intent URI, which is fired at the end of the Link flow.

intent://redirect/#Intent;scheme=plaid;package=$packageName;end;

Our SDK includes an intent filter to handle the URI, so that the application re-opens and makes a call directly to the channel immediately.

<intent-filter>
  <action android:name="android.intent.action.VIEW" />
 
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
 
  <data
    android:host="redirect"
    android:scheme="plaid" />
</intent-filter>

Controlling CCT

CCT lacks interfaces for the app to:

  • listen for when the user closes the activity
  • detect when the user has clicked the “Open in Browser” option
  • force it to close

We have successfully worked around all these shortcomings.

First, to listen for when the user closes the activity, we open CCT using startActivityForResult and pass it a request code.  If the user closes CCT using the X in the upper left corner of the system back button, the onActivityResult callback is triggered with the request code we provided and a result code of Activity.RESULT_CANCELED.  The data intent does not include any information, but we can make a final call to the channel to get the remaining events. We then pass them to the client app and return a LinkCancellation object, signaling that Link was closed intentionally by the user.

Next, the potential concern with detecting when the user has clicked “Open in Browser” is that the user could then go through the entire flow in a separate application, namely the browser.  This is not a problem for us, because the polling and intent systems continue to work in the same manner and we can still get the data needed.

Finally, when the user completes the flow successfully and the result intent is fired, the CCT process would remain open and in the user's task list.  This phantom process would not only be wasteful, but could also be confusing to a user when they press the "recent tasks" system button.  Therefore, we need a way to force CCT to close when the flow is complete.

In order to do this, we used the pattern shown in the OpenId AppAuth for Android library. Instead of handling the result in the activity that opens Link, we place the intent filter on a separate activity.  This second activity handles all the redirects from the web app, which include: successful completions, errors that close the flow, oAuth or App-to-App redirects, and general system errors.  The activity then passes the data back to the opening activity using an intent with the Intent.FLAG_ACTIVITY_SINGLE_TOP and Intent.FLAG_ACTIVITY_CLEAR_TOP flags.  Used together, they clear everything on the stack above the opening activity, including CCT.


val intent = Intent(activity, LinkActivity::class.java)
when (state) {
  is RedirectState.ChromeCustomTabsComplete -> {
    intent.putExtra(LINK_CHROME_CUSTOM_TABS_COMPLETE_REDIRECT, true)
    intent.putExtra(LINK_RESULT_CODE, state.resultCode)
    intent.putExtra(LINK_RESULT, state.result)
  }
  is RedirectState.UserInitiatedChromeCustomTabsViewClose -> {
    intent.putExtra(LINK_CHROME_CUSTOM_TABS_USER_CLOSE_REDIRECT, true)
  }
  is RedirectState.OAuth -> {
    intent.putExtra(LINK_OAUTH_REDIRECT, true)
    intent.putExtra(LINK_OAUTH_STATE_ID, state.oauthStateId)
  }
  is RedirectState.RedirectError ->
    intent.putExtra(LINK_REDIRECT_ERROR, true)
}
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
return intent

Final thoughts on CCT

In order to provide a secure, sandboxed experience not available in WebViews or native flows, CCT is a viable solution. It is easy to integrate with for developers and provides a better user experience than opening browser windows thanks to its lightweight nature and speed.  

CCT’s sandboxed nature and limited API mean it is not without its downsides.  Listening to URL redirects, getting final results and controlling the CCT process all required us to come up with creative solutions.  These solutions relied on an understanding of Android's built-in features, especially the intent framework.

The benefits for developers and consumers were well worth the required effort and we recommend using CCT in other apps and SDKs for a fast and secure integration. Moreover, you can use the tips provided here to improve the user (and developer) experience.

If you are interested in solving unique problems that will be used by thousands of developers and millions of consumers, visit our careers page.

Join us!