World ID Critique

Dick Hardt

--

OpenID Connect Implementation Antipatterns

On July 24th, Worldcoin publicly launched World ID, and many friends and colleagues pinged me to get my opinion. I was attending IETF 117 and this past weekend I finally had time to start exploring the service now that it has launched. There has been TONS of coverage on the orb, the biometrics, and the coin. I’m going to review their OpenID Connect implementation.

This evaluation was made on August 7, 2023 using a production deployment of the Hellō app on Worldcoin; Worldcoin’s Sign in with Worldcoin Reference; and their OIDC Explainer.

Update August 9: On August 8, I provided a draft of this document to Worldcoin prior to publication. They have addressed a number of issues as noted below.

Tl;dr:

  1. If you are a developer considering adding World ID to your project. Wait.
  2. If you see an app using World ID. Be safe.
  3. The OAuth Best Security Current Practices have not been followed. Combined with the following point, applications using World ID may be vulnerable to attacks.
  4. The implementation is not compliant with the OpenID Connect specification. Times are in milliseconds instead of seconds, requests can be made without required parameters. Update Aug 9, these have been addressed.
  5. The user’s privacy is being violated. The authorization page presents no information on what the application is requesting, nor on what worldcoin.org is releasing. There are no application terms of service and privacy policy links.

OpenID Connect Background

OpenID Connect (OIDC) is a popular identity protocol used by social login services such as Apple, Google, Facebook, and Microsoft. There are libraries in all popular programming languages, and plug-ins for all popular platforms.

The typical user experience with OpenID Connect starts with clicking on a button in the app, and then redirecting you to your provider — Apple, Google etc. If you don’t have an active session, you are prompted to login to your provider. If you have not used the app where you started, you will be shown what will be shared, and prompted if you want to continue. If you continue, you will be redirected back to the app sharing your information. There are a few variants on what is sent back and forth between the app and your provider, but the end result is usually an ID Token that is digitally signed by the provider. The contents of the token indicate who issued it, which user it is for, when it was issued, when it expires, which app requested it, and handshake data to ensure the token is for the user that started the flow.

OpenID Connect is built on top of OAuth 2.0, the most popular authorization protocol on the internet. (I happen to have led the initial design, and am listed as the editor.) These protocols have been around for over a decade and have had significant security analysis and improvements including in the IETF OAuth meetings, the OpenID Foundation, and OAuth Security Workshop. There are 28 published IETF RFCs and 12 OpenID Connect specifications.

Worldcoin launched with support for OpenID Connect, significantly lowering the barrier for developer adoption, and building on the decade of security and deployment experience. (My startup, Hellō chose to support OpenID Connect for the same reasons.)

There are two significant documents that have been under development for a couple of years: the OAuth 2.0 Best Security Practices; and OAuth 2.1. The latter does not introduce any new features, but compiles a dozen documents into a single coherent document that removes conflicting information.

Unfortunately it does not look like Worldcoin consulted them.

Adding World ID to Hellō

I have integrated all the popular OpenID Connect providers into Hellō (Apple, Facebook, GitLab, Google, Line, Microsoft, Twitch, Yahoo) and OAuth 2.0 providers (Discord, GitHub, Instagram, Mastodon, Reddit, Tumblr, Twitter, and WordPress). We also support Metamask, WalletConnect, and passkeys. Our code deals with how each of the providers is their own “special snowflake”, so I thought adding World ID would be straightforward, and we would have access to the unique “human” claim Worldcoin promised.

Signing up for World ID

To access the Developer Portal, you need a World ID which requires you to install the World App. I installed it on my iPhone, verified my phone number, and was then prompted to create a password. The app then crashed. Oops! After restarting, the app displayed “Creating World ID” and a counter that indicated it was going to take some time. I set my phone aside and when I checked it later it had crashed again. I restarted the app, was shown the passport-like display, and then once again displayed “Creating World ID”. As you can see from the screen grabs, some time passed between the initial install screen and the other screen shots included below. Eventually the World App was happy and I had a World ID.

A password? It’s 2023. Why not support passkeys? All modern iOS and Android devices support it. I understand the desire to support older and less expensive devices, but it makes no sense to not support stronger, phishing resistant authentication when available. Passwords just increase the attack surface.

Face ID Opt in? The World App by default is able to log into a site without further authentication. There is no PIN or Face ID when you open the World App, or use it to log into another app. IE, if your phone is unlocked, someone can use it to login to a website just by scanning the QR code. If you poke around in the settings, you can enable Face ID, but I was not prompted to turn it on.

World ID Developer Portal

To access the developer portal, you must log in with your World ID. The website shows a QR code that you scan with the World App on your phone. The process is smooth, and you see a logo verified mark beside Worldcoin Developer Portal giving me confidence I’m logging into the correct app with my World ID.

Developer Experience

If you are not a developer, you may be tempted to skip this section. Don’t. There is not much to it, and that is where the issues start.

After logging into the developer portal, I was prompted to enter and then verify my email address. Creating an app was straightforward. (Almost as straightforward as at the Hellō Console ;)

The initial delight of the simple experience disappeared when I realized key configuration was missing. There was no mechanism to upload a logo. No terms of service or privacy policy urls. No verification process so my app would show up verified like the Worldcoin Developer Portal.

register API

The Sign In Reference documents a Register App API, an alternative to registering through the portal.

The one required parameter is “redirect_uris”, which is mislabelled as a string, despite the example showing it as an array of strings. The description:

URLs the user will be redirected to after authentication. Must be HTTPS, and can always be updated in the Developer Portal.

… is strange, as there is no authentication with the API, so how would Worldcoin know which application I can update?

The example curl code that you can copy to run yourself:

curl -X POST https://id.worldcoin.org/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "Example Application",
"logo_uri": "https://app.example.com/logo.svg",
"redirect_uris": ["https://app.example.com/callback", "https://app.example.com/redirect"],
"application_type": "web",
"grant_types": "authorization_code",
"response_types": "code"
}'

… shows an SVG file being provided as the logo. Wow. No providers allow SVG files due to the XSS security risks of an attacker embedding malicious JS in the image, which would then be loaded on id.worldcoin.org if the SVG was not run through a sophisticated filter.

Worldcoin responded to this concern on Aug 9:

Worldcoin developer portal is not vulnerable for the following reasons:

- The Worldcoin developer portal has a strong Content Security Policy (CSP) that prevents external images like a malicious SVG from loading entirely.

- The Worldcoin developer portal uses <img> tags to embed logo_url content, which prevents embedded scripts inside SVGs from executing.

- Additionally, apps must currently be manually verified to display logos, which has the added benefit of preventing impersonation scams.

UPDATE Aug 12: the example shows using a .png instead of a .svg file. The API provides the same error though.

Unfortunately I was not able to test this (or fortunately given all the security risks of an anonymous registration API?) the API was not working. I tried a number of parameter permutations, but the server would always would return an HTTP 500 response code and:

{
"code": "server_error",
"message": "Internal server error. Please try again."
}

World ID Login Experience

With a configured app, I could now check out the World ID user experience on my own app. (See below for how I did this without writing code.)

Starting the flow in a browser looks similar to the login flow for the developer portal. Looking more carefully, it looks exactly the same. There is no indication that I am logging into my app vs the Worldcoin Developer Portal.

In the World App, the flow looks similar with the exception that Hellō is not a verified app. The only signals of which app is being logged into are the letter “H” as the logo, and “Hellō” as the app name. Note there is no terms or service or privacy policy links for Hellō anywhere, and no indication of what is being shared with Hellō.

Platform Detection

While resizing my browser window to access the developer tools, I noticed the web interface naively just looks at the window width to decide if it is on a mobile device. Pity the poor user trying to use their World ID on a web app where the browser window is narrow.

Developer Portal Session Length

I went back to the developer portal a day later, and my session was still active. I would have expected it to time out after an hour of inactivity at most. Sure, I should have logged out, but an attacker could take over a sloppy developer’s app.

Protocol Exploration

Ok, we are going to start to get a little more technical. If you have a some idea of how the web works, you should be able to follow along.

Given the nature of OpenID Connect, you don’t need to write any code to explore what a provider will do. You can open up a URL with the right parameters to make an authorization request, and then use the browser’s developer tools to see what was sent back. This is all I did in my exploration.

I started off with a simple and incomplete request to see what errors I got. (new lines are for readability)

https://id.worldcoin.org/authorize
?client_id=app_404ea2eb1e2d5155c05c3b2878ca1ade
&response_type=id_token
&redirect_uri=https%3A%2F%2Fwallet.hello.coop%2Foauth%2Fresponse%2Fworldcoin

To my surprise, this worked. I got back the following JSON in the ID Token.

{
"header": {
"alg":"RS256",
"typ":"JWT",
"kid":"jwk_dad75cf7c4bc159276c2c748a196b11d"
},
"payload": {
"iss":"https://id.worldcoin.org",
"sub":"0x233c7136721dd85256e936a6a0b5a67b0f904bed6d1ce91d6d7364e9064f9cc2",
"jti":"7fcd4fb6-63c4-4019-9ed2-43b14af657e0",
"iat":1691444766901,
"exp":1691448366901,
"aud":"app_404ea2eb1e2d5155c05c3b2878ca1ade",
"scope":"",
"https://id.worldcoin.org/beta": {
"likely_human":"weak",
"credential_type":"phone"
},
"nonce":"1691444748611"
}
}

Why was I surprised? I should have gotten an error response.

The “scope” parameter is required per OpenID Connect, and must contain the “openid” scope to differentiate between an OpenID Connect request and an OAuth 2.0 request.

UPDATE: Aug 9
An error is now returned if the “openid” scope is not provided

Either a “nonce” or a PKCE “code_challenge” should be required per security best practices to prevent replay attacks per OAuth Security Best Practices. As this is an implicit flow, it would have to be a “nonce”.

UPDATE: Aug 9
An error is now returned if a “nonce” is not provided
UPDATE: Aug 12
I only checked the “id_token” request type. Reading the docs I noticed they state the nonce is only required for the “id_token” request type, not the “code” request type. That is not spec compliant. It is required for the code request type as well since it is required to be in the “id_token” you get back when exchanging the code at the token endpoint.

UPDATE: Aug 12
I had only been testing the “id_token” response type. The “code” response type does NOT return a “nonce” in the ID token even if one is provided in the authorization request. IE, there is no way to check that the “id_token” you received is associated with the session that started the flow. While an attacker can inject a “code” and have you receive back a different ID Token. This is not a huge risk though since if you are validating the ID Token, it will fail since there is no “nonce”!

Looking at the ID Token, I was even more surprised. The “iat” (issued at) and “exp” (expiry) claims are in milliseconds instead of seconds. Most validation libraries will consider the ID Token to be valid as it was issued in the far future. This online parser thinks the token was issued at November 10, 55569, over 53,000 years from now.

UPDATE: Aug 9
This has been fixed. “iat” and “exp” are now in seconds

Despite not providing a “nonce” in the request, I got back one with the value of “1691444748611”, a millisecond time stamp that is 18290 ms prior to when the ID Token was issued (1691444766901). A nonce should be a random value, so clearly there is some logic failure here as there should not be a “nonce” in the token as one was not provided.

My last surprise in the ID Token was the “https://id.worldcoin.org/beta" claim:

"https://id.worldcoin.org/beta": {
"likely_human":"weak",
"credential_type":"phone"
}

As a user, I had not been informed that this data was going to be shared. As a developer, I had not asked for it. I consider this a privacy issue.

Next I tried sending a valid request. The valid scopes are not documented, but poking around at the examples I saw that “openid”, “email”, and “profile” were being used. Here is what I sent next, that includes a “nonce” and “scope” parameters:

https://id.worldcoin.org/authorize
?client_id=app_404ea2eb1e2d5155c05c3b2878ca1ade
&response_type=id_token
&redirect_uri=https%3A%2F%2Fwallet.hello.coop%2Foauth%2Fresponse%2Fworldcoin
&nonce=nonce1
&scope=openid+email+profile

And I received the following JSON back in the ID Token:

{
"header": {
"alg":"RS256",
"typ":"JWT",
"kid":"jwk_dad75cf7c4bc159276c2c748a196b11d"
},
"payload": {
"iss":"https://id.worldcoin.org",
"sub":"0x233c7136721dd85256e936a6a0b5a67b0f904bed6d1ce91d6d7364e9064f9cc2",
"jti":"bc24c584-e061-4c62-aaa1-c85cbb38579e",
"iat":1691443980673,
"exp":1691447580673,
"aud":"app_404ea2eb1e2d5155c05c3b2878ca1ade",
"scope":"openid email profile",
"https://id.worldcoin.org/beta":{
"likely_human":"weak",
"credential_type":"phone"
},
"nonce":"nonce1",
"email":"0x233c7136721dd85256e936a6a0b5a67b0f904bed6d1ce91d6d7364e9064f9cc2@id.worldcoin.org",
"name":"World ID User",
"given_name":"World ID",
"family_name":"User"
}
}

We got back the “nonce” value we sent. The “email”, “name”, “given_name”, “family_name” claims are not useful. Based on the documentation, it looks like they included them because some library complained.

Directed Identifiers

Next up was checking what was different in ID Tokens between different applications. I was hoping to confirm a different identifier, specifically the “sub”, would be different for me across applications per the 4th Law of Identity.

My app was given:

"sub":"0x233c7136721dd85256e936a6a0b5a67b0f904bed6d1ce91d6d7364e9064f9cc2"

… and the developer portal was given a different identifier, which is what we want:

"sub":"0x29a6e7c076c0922e146e96c92b5d4c8833818cc89166f483903a731d2e757253"

… and the second production app I setup was given yet another identifier:

"sub":"0x2d20b5094a2e2f8be4542f40e7eca9b701b4763877cc4fede42dfa6ea756a31c"

The latter is going to be a problem for developers as they want to know it is the same user across apps. Many large providers that only allowed one app per developer have kludged together mechanisms for developers to get this common identifier. Wanting the same user identifiers across each of developer’s app will motivate them to use the same “client_id” and “client_secret” across apps, dissolving the separation of concerns.

Access Token

The next thing I explored was setting “response_type” to “token” instead of an “id_token”. This provides an access token that can be used to call the user_info endpoint, and potentially any other API exposed by the provider.

First of all, this should not have worked in an implicit flow where the “token” is returned as a redirect parameter. It is now in the browser history and available to any malicious code with access. (Security BCP 4.3.2)

The bigger issue is that the access token is an ID Token. There was no difference in the token format in conflict with RFC 8752 section 3.11 and 3.12, conflating authorization with authentication.

response_mode

The default mechanism to return the results from an authorization request is to include them as query parameters. This has the downside of sensitive parameters such as tokens being captured in server logs that save the URL being requested, and potentially exposes the parameters to unencrypted wireless networks.

The “fragment” response mode returns the results in the URL fragment, which is not sent to the server by the browser. This allows a web app to access the results. The “form_post” mode passes the parameters to the server as a form post rather than as query parameters, keeping the parameters out of the server logs of the page request.

I tried getting results by passing the “response_mode” parameter set to both “fragment” and “form_post”. Unfortunately, Worldcoin ignores the “response_mode” parameter. All results are returned as query parameters.

Documentation Concerns

Developers depend on a provider’s documentation to guide them. Unfortunately, the Worldcoin documentation is lacking, leading to developer frustration, or worse, vulnerable applications.

The only explanation for how to start the OpenID Connect flow is in Further Reading | OIDC Explainer, not where a developer would start. The Quickstart tells developers to following the Auth0 Integration.

I’m surprised the Auth0 integration would work given the non-standard ID Token “iss” and “iat” claims — but perhaps it only uses a token flow? — I have not investigated.

nonce

The OIDC Explainer casually mentions the “nonce”, does not say it is required (it should be), and provides no explanation what the developer should do to confirm the ID Token they have received is linked to the authorization request they started, which is one of the purposes of the ”nonce”.

scope

No mention of the “scope” parameter, which is required. No list of valid scopes is provided. I figured out the “openid”, “email”, and “profile” scopes by looking at examples. While “openid” is required, the “profile” scope return generic information, the “email” claim is just the “sub” claim prepended to “@id.worldcoin.org”.

UPDATE Aug 12:
The documentation has been updated to include the “scope” parameter and shows “openid%20profile%20email”, so one can more easily guess what scopes are allowed. The docs say that the default scope is “openid”, but it is now required to be spec compliant. The “nonce” parameter is now stated as required when using id_token response type, although best practice is for there to be a nonce in the id_token returned for “code” response types as well. The docs are moving in the right direction!

Closing Thoughts

Given the $125M raised and the involvement of Sam Altman of Y Combinator and OpenAI fame, I had expected a robust OpenID implementation that would have lowered the bar for developers to adopt World ID and inspired innovation.

This disregard for following the standard and published best security practices is alarming. Not informing a user about what is being released about them displays a disregard for user privacy. A step backwards for the identity industry.

--

--