DEV Community

Cover image for Why OAuth Uses an Authorization Code Instead of Just Handing Over the Token
Theophilus Frimpong
Theophilus Frimpong

Posted on • Originally published at littlegod.hashnode.dev

Why OAuth Uses an Authorization Code Instead of Just Handing Over the Token

Every time you click 'Sign in with Google' on a third-party app, a two-step dance happens behind the scenes. The Google OAuth server returns a short-lived authorization code and the third-party server exchanges that code to get an access token. This token is then used to retrieve the data needed by the third-party server from Google. When building my own OAuth server, I asked myself why this two-step dance existed. Why doesn't the OAuth server just return the access token without the additional step of sending a short-lived authorization code? The reason behind that design broadened my perspective on how data travels.

After a user approves for a third party to use scoped resources from the OAuth server, the server sends something back by redirecting the user's browser to the app's redirect_uri with the value attached as a query parameter, ?code=abc123. This travels through the browser, in the URL itself.

A browser's history stores all URLs that have been visited. This URL will be no exception. In the Network Tab of the browser, the authorization code can also be located in the Headers tab's Request URL as well as the Referer Header. Server logs also show request logs made which will include the URL with the authorization code when called. Evidently, the authorization code leaves copies of itself in several places you don't fully control. So why use the code at all?

Because of how exposed this code is, it's short-lived. Usually 60 seconds or below. Anyone who gets access to the code has barely a minute to utilize it. It is also single-use. A used authorization code cannot be used again. These restrictions however, aren't enough. Which leads us to what other information an OAuth server needs to return user consented data. Before a third-party server can access data from an OAuth server, it must be first registered to it. A registered server must have a client_id — which is the name the OAuth uses to identify the server, a client_secret — essentially a password for the third-party server and a redirect_uri , the path where the OAuth server returns the consent screen and authorization code.

The client_secret is the most important credential that must be kept secured and tucked away from the public. It most commonly resides in the third-party server. The OAuth requires the client_secret and client_id in addition to the authorization code for retrieving user data. So unless whoever holds the code also has the client_secret and client_id, it's impossible to get the user data.

The third-party server retrieves the authorization code from the browser, adds the other required credentials and directly calls the OAuth server's token endpoint. The OAuth server will verify all credentials sent by the third-party app before returning the access token. This direct call to the OAuth server prevents the client_secret from getting exposed like how the authorization code does. The access token will then be used by the third-party server to finally retrieve the user data.

router.post('/token', async (req, res) => {
  const { grant_type, code, client_id, client_secret, redirect_uri } = req.body

  if (!client_id || !client_secret) {
    return res.status(400).json({ message: 'Missing required values: client_id, client_secret' })
  }

  const client = await findClientByClient_Id(client_id)

  if (!client) {
    return res.status(404).json({ message: 'Client not found' })
  }

  // verify the secret against the stored hash
  const isValid = await bcrypt.compare(client_secret, client.client_secret)
  if (!isValid) {
    return res.status(401).json({ message: 'Invalid client secret' })
  }

  if (redirect_uri !== client.redirect_uri) {
    return res.status(400).json({ message: 'redirect_uri mismatch' })
  }

  // validate the authorization code
  const auth_code = await findAuthCode(code)
  if (!auth_code || auth_code.is_used || auth_code.client_id !== client.id || auth_code.expires_at < new Date()) {
    return res.status(404).json({ message: 'Invalid or missing authorization code' })
  }

  // mark the code as used so it can never be replayed
  await useAuthCode(auth_code.id)

  // issue the access token and return it over this direct server-to-server channel
})
Enter fullscreen mode Exit fullscreen mode

The snippet above is my custom OAuth server illustrating the verification of the registered client (third-party server). The client_secret coming in, is checked against the stored hash of the client_secret existing in the OAuth server's database. The bcrypt.compare method is used for this. The secret is stored only as a hash, even a breach of the OAuth server's database wouldn't hand an attacker usable secrets.

To ensure that the OAuth returns the token to the right server, it needs to also check if the redirect_uri matches the uri it has stored. Recall that the authorization code should be single-use. The auth_code.is_used checks this. If the code has been used, it returns an invalid error message. The code should also have a short life span. The auth_code.expires_at property checks this. The authorization code must be bound to the app it was issued to so we check if the client_id coming in is the same client_id calling for the generated code.

The system design here shows why the authorization code can travel in the open channel because on its own it's nearly worthless; the actual key is the token and it's protected through the server-to-server channel.

That's the justification for the two-step dance. The code and the token travel different paths because they carry different risks. The code is cheap, so it can go through the browser where things leak. The token is the real key, so it never touches the browser at all. That was the answer to the question I started with, and it changed how I think about where sensitive data is allowed to travel.

One footnote: public clients like mobile apps and single-page apps can't safely hold a client_secret at all. They use a variant called PKCE that swaps the static secret for a dynamic per-request challenge. It follows a similar flow to the one shown above.

Top comments (0)