diff --git a/inc/class-client.php b/inc/class-client.php
index 6ea60c7..9871ffe 100644
--- a/inc/class-client.php
+++ b/inc/class-client.php
@@ -130,6 +130,35 @@ public function get_redirect_uris() {
 		return (array) get_post_meta( $this->get_post_id(), static::REDIRECT_URI_KEY, true );
 	}
 
+	/**
+	 * Does the client require secrets to be validated?
+	 *
+	 * Clients marked as confidential are required to have their client
+	 * credentials (i.e. secret) checked.
+	 *
+	 * @link https://tools.ietf.org/html/rfc6749#section-3.2.1
+	 *
+	 * @return bool True if secret must be verified, false otherwise.
+	 */
+	public function requires_secret() {
+		$type = $this->get_type();
+		return $type === 'private';
+	}
+
+	/**
+	 * Check if a secret is valid.
+	 *
+	 * This method ensures that the secret is correctly checked using
+	 * constant-time comparison.
+	 *
+	 * @param string $supplied Supplied secret to check.
+	 * @return boolean True if valid secret, false otherwise.
+	 */
+	public function check_secret( $supplied ) {
+		$stored = $this->get_secret();
+		return hash_equals( $supplied, $stored );
+	}
+
 	/**
 	 * Validate a callback URL.
 	 *
diff --git a/inc/endpoints/class-token.php b/inc/endpoints/class-token.php
index e97914b..dc64822 100644
--- a/inc/endpoints/class-token.php
+++ b/inc/endpoints/class-token.php
@@ -54,18 +54,62 @@ public function validate_grant_type( $type ) {
 	 * @return array|WP_Error Token data on success, or error on failure.
 	 */
 	public function exchange_token( WP_REST_Request $request ) {
-		$client = Client::get_by_id( $request['client_id'] );
+		// Check headers for client authentication.
+		// https://tools.ietf.org/html/rfc6749#section-2.3.1
+		if ( isset( $_SERVER['PHP_AUTH_USER'] ) ) {
+			$client_id = $_SERVER['PHP_AUTH_USER'];
+			$client_secret = $_SERVER['PHP_AUTH_PW'];
+		} else {
+			$client_id = $request['client_id'];
+			$client_secret = $request['client_secret'];
+		}
+
+		if ( empty( $client_id ) ) {
+			// invalid_client
+			return new WP_Error(
+				'oauth2.endpoints.token.exchange_token.no_client_id',
+				__( 'Missing client ID.'),
+				array(
+					'status' => WP_Http::UNAUTHORIZED,
+				)
+			);
+		}
+
+		$client = Client::get_by_id( $client_id );
 		if ( empty( $client ) ) {
 			return new WP_Error(
 				'oauth2.endpoints.token.exchange_token.invalid_client',
-				sprintf( __( 'Client ID %s is invalid.', 'oauth2' ), $request['client_id'] ),
+				sprintf( __( 'Client ID %s is invalid.', 'oauth2' ), $client_id ),
 				array(
 					'status' => WP_Http::BAD_REQUEST,
-					'client_id' => $request['client_id'],
+					'client_id' => $client_id,
 				)
 			);
 		}
 
+		if ( $client->requires_secret() ) {
+			// Confidential client, secret must be verified.
+			if ( empty( $client_secret ) ) {
+				// invalid_request
+				return new WP_Error(
+					'oauth2.endpoints.token.exchange_token.secret_required',
+					__( 'Secret is required for confidential clients.', 'oauth2' ),
+					array(
+						'status' => WP_Http::UNAUTHORIZED
+					)
+				);
+			}
+			if ( ! $client->check_secret( $client_secret ) ) {
+				return new WP_Error(
+					'oauth2.endpoints.token.exchange_token.invalid_secret',
+					__( 'Supplied secret is not valid for the client.', 'oauth2' ),
+					array(
+						'status' => WP_Http::UNAUTHORIZED
+					)
+				);
+			}
+		}
+
 		$auth_code = $client->get_authorization_code( $request['code'] );
 		if ( is_wp_error( $auth_code ) ) {
 			return $auth_code;