diff --git a/modules/jsonapi_file_upload/jsonapi_file_upload.info.yml b/modules/jsonapi_file_upload/jsonapi_file_upload.info.yml
new file mode 100644
index 0000000..5e6b923
--- /dev/null
+++ b/modules/jsonapi_file_upload/jsonapi_file_upload.info.yml
@@ -0,0 +1,7 @@
+name: JSON API File Upload
+type: module
+description: Provides JSON API file upload support.
+core: 8.x
+package: Web services
+dependencies:
+  - jsonapi
diff --git a/modules/jsonapi_file_upload/jsonapi_file_upload.routing.yml b/modules/jsonapi_file_upload/jsonapi_file_upload.routing.yml
new file mode 100644
index 0000000..c82aa0d
--- /dev/null
+++ b/modules/jsonapi_file_upload/jsonapi_file_upload.routing.yml
@@ -0,0 +1,2 @@
+route_callbacks:
+  - '\Drupal\jsonapi_file_upload\Routing\Routes::routes'
diff --git a/modules/jsonapi_file_upload/jsonapi_file_upload.services.yml b/modules/jsonapi_file_upload/jsonapi_file_upload.services.yml
new file mode 100644
index 0000000..8a44b01
--- /dev/null
+++ b/modules/jsonapi_file_upload/jsonapi_file_upload.services.yml
@@ -0,0 +1,14 @@
+services:
+  jsonapi_file_upload.file_uploader:
+    class: Drupal\jsonapi_file_upload\Shim\FileUploader
+    public: false
+    arguments: ['@logger.channel.jsonapi_file_upload', '@file_system', '@file.mime_type.guesser', '@token', '@lock', '@config.factory']
+
+  logger.channel.jsonapi_file_upload:
+    parent: logger.channel_base
+    arguments: ['jsonapi_file_upload']
+
+  # Controllers.
+  jsonapi_file_upload.request_handler:
+    class: Drupal\jsonapi_file_upload\Controller\RequestHandler
+    arguments: ['@current_user', '@entity_field.manager', '@jsonapi_file_upload.file_uploader', '@http_kernel']
diff --git a/modules/jsonapi_file_upload/src/Controller/RequestHandler.php b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php
new file mode 100644
index 0000000..7836ce3
--- /dev/null
+++ b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php
@@ -0,0 +1,205 @@
+<?php
+
+namespace Drupal\jsonapi_file_upload\Controller;
+
+use Drupal\Core\Access\AccessResultReasonInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Url;
+use Drupal\jsonapi\Entity\EntityValidationTrait;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\ResourceResponse;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi_file_upload\Shim\FileUploader;
+use Drupal\jsonapi_file_upload\Shim\FileUploaderInterface;
+use Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\HttpKernel\HttpKernelInterface;
+
+/**
+ * Handles file upload requests.
+ *
+ * @internal
+ */
+class RequestHandler {
+
+  use EntityValidationTrait;
+
+  /**
+   * The current user making the request.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $fieldManager;
+
+  /**
+   * The file uploader.
+   *
+   * @var \Drupal\jsonapi_file_upload\Shim\FileUploaderInterface
+   */
+  protected $fileUploader;
+
+  /**
+   * An HTTP kernel for making subrequests.
+   *
+   * @var \Symfony\Component\HttpKernel\HttpKernelInterface
+   */
+  protected $httpKernel;
+
+  /**
+   * Creates a new RequestHandler instance.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
+   *   The entity field manager.
+   * @param \Drupal\jsonapi_file_upload\Shim\FileUploaderInterface $file_uploader
+   *   The file uploader.
+   * @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel
+   *   An HTTP kernel for making subrequests.
+   */
+  public function __construct(AccountInterface $current_user, EntityFieldManagerInterface $field_manager, FileUploaderInterface $file_uploader, HttpKernelInterface $http_kernel) {
+    $this->currentUser = $current_user;
+    $this->fieldManager = $field_manager;
+    $this->fileUploader = $file_uploader;
+    $this->httpKernel = $http_kernel;
+  }
+
+  /**
+   * Handles JSON API file upload requests.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request object.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON API resource type for the current request.
+   * @param string $file_field_name
+   *   The file field for which the file is to be uploaded.
+   * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
+   *   The entity for which the file is to be uploaded.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response object.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   *   Thrown if the upload's target resource could not be saved.
+   */
+  public function handleFileUploadForExistingResource(Request $request, ResourceType $resource_type, $file_field_name, FieldableEntityInterface $entity) {
+    $field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $file_field_name);
+
+    static::ensureFileUploadAccess($this->currentUser, $field_definition, $entity);
+
+    $filename = FileUploader::validateAndParseContentDispositionHeader($request);
+    $stream = FileUploader::getUploadStream();
+    $file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $stream, $this->currentUser);
+    fclose($stream);
+
+    if ($field_definition->getFieldStorageDefinition()->getCardinality() === 1) {
+      $entity->{$file_field_name} = $file;
+    }
+    else {
+      $entity->get($file_field_name)->appendItem($file);
+    }
+    static::validate($entity, [$file_field_name]);
+    $entity->save();
+
+    $route_parameters = [$resource_type->getEntityTypeId() => $entity->uuid()];
+    $route_name = sprintf('jsonapi.%s.%s.related', $resource_type->getTypeName(), $file_field_name);
+    $related_url = Url::fromRoute($route_name, $route_parameters)->toString(TRUE);
+    return $this->httpKernel->handle(Request::create($related_url->getGeneratedUrl()), HttpKernelInterface::SUB_REQUEST);
+  }
+
+  /**
+   * Handles JSON API file upload requests.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request object.
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The JSON API resource type for the current request.
+   * @param string $file_field_name
+   *   The file field for which the file is to be uploaded.
+   *
+   * @return \Drupal\jsonapi\ResourceResponse
+   *   The response object.
+   */
+  public function handleFileUploadForNewResource(Request $request, ResourceType $resource_type, $file_field_name) {
+    $field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $file_field_name);
+
+    static::ensureFileUploadAccess($this->currentUser, $field_definition);
+
+    $filename = FileUploader::validateAndParseContentDispositionHeader($request);
+    $stream = FileUploader::getUploadStream();
+    $file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $stream, $this->currentUser);
+    fclose($stream);
+
+    return new ResourceResponse(new JsonApiDocumentTopLevel($file), '200', []);
+  }
+
+  /**
+   * Ensures that the given account is allowed to upload a file.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The account for which access should be checked.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field for which the file is to be uploaded.
+   * @param \Drupal\Core\Entity\FieldableEntityInterface|null $entity
+   *   The entity, if one exists, for which the file is to be uploaded.
+   */
+  protected static function ensureFileUploadAccess(AccountInterface $account, FieldDefinitionInterface $field_definition, FieldableEntityInterface $entity = NULL) {
+    $access_result = $entity
+      ? FileUploader::checkFileUploadAccess($account, $field_definition, $entity)
+      : FileUploader::checkFileUploadAccess($account, $field_definition);
+    if (!$access_result->isAllowed()) {
+      $reason = 'The current user is not permitted to upload a file for this field.';
+      if ($access_result instanceof AccessResultReasonInterface) {
+        $reason .= ' ' . $access_result->getReason();
+      }
+      throw new AccessDeniedHttpException($reason);
+    }
+  }
+
+  /**
+   * Validates and loads a field definition instance.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID the field is attached to.
+   * @param string $bundle
+   *   The bundle the field is attached to.
+   * @param string $field_name
+   *   The field name.
+   *
+   * @return \Drupal\Core\Field\FieldDefinitionInterface
+   *   The field definition.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
+   *   Thrown when the field does not exist.
+   * @throws \Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException
+   *   Thrown when the target type of the field is not a file, or the current
+   *   user does not have 'edit' access for the field.
+   */
+  protected function validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name) {
+    $field_definitions = $this->fieldManager->getFieldDefinitions($entity_type_id, $bundle);
+    if (!isset($field_definitions[$field_name])) {
+      throw new NotFoundHttpException(sprintf('Field "%s" does not exist', $field_name));
+    }
+
+    /** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */
+    $field_definition = $field_definitions[$field_name];
+    if ($field_definition->getSetting('target_type') !== 'file') {
+      throw new AccessDeniedException(sprintf('"%s" is not a file field', $field_name));
+    }
+
+    return $field_definition;
+  }
+
+}
diff --git a/modules/jsonapi_file_upload/src/JsonapiFileUploadServiceProvider.php b/modules/jsonapi_file_upload/src/JsonapiFileUploadServiceProvider.php
new file mode 100644
index 0000000..a2b65c2
--- /dev/null
+++ b/modules/jsonapi_file_upload/src/JsonapiFileUploadServiceProvider.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\jsonapi_file_upload;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceModifierInterface;
+use Drupal\Core\StackMiddleware\NegotiationMiddleware;
+
+/**
+ * Adds 'application/octet-stream' as a known (bin) format.
+ *
+ * @internal
+ */
+class JsonapiFileUploadServiceProvider implements ServiceModifierInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    if ($container->has('http_middleware.negotiation') && is_a($container->getDefinition('http_middleware.negotiation')->getClass(), NegotiationMiddleware::class, TRUE)) {
+      $container->getDefinition('http_middleware.negotiation')->addMethodCall('registerFormat', ['bin', ['application/octet-stream']]);
+    }
+  }
+
+}
diff --git a/modules/jsonapi_file_upload/src/Routing/Routes.php b/modules/jsonapi_file_upload/src/Routing/Routes.php
new file mode 100644
index 0000000..df82193
--- /dev/null
+++ b/modules/jsonapi_file_upload/src/Routing/Routes.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace Drupal\jsonapi_file_upload\Routing;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\jsonapi\ParamConverter\ResourceTypeConverter;
+use Drupal\jsonapi\ResourceType\ResourceType;
+use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
+use Symfony\Cmf\Component\Routing\RouteObjectInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+use Drupal\jsonapi\Routing\Routes as JsonApiRoutes;
+
+/**
+ * Defines dynamic routes.
+ *
+ * @internal
+ */
+class Routes implements ContainerInjectionInterface {
+
+  /**
+   * The JSON API resource type repository.
+   *
+   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
+   */
+  protected $resourceTypeRepository;
+
+  /**
+   * List of providers.
+   *
+   * @var string[]
+   */
+  protected $providerIds;
+
+  /**
+   * The JSON API base path.
+   *
+   * @var string
+   */
+  protected $jsonApiBasePath;
+
+  /**
+   * Instantiates a Routes object.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
+   *   The JSON API resource type repository.
+   * @param string[] $authentication_providers
+   *   The authentication providers, keyed by ID.
+   * @param string $jsonapi_base_path
+   *   The JSON API base path.
+   */
+  public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, array $authentication_providers, $jsonapi_base_path) {
+    $this->resourceTypeRepository = $resource_type_repository;
+    $this->providerIds = array_keys($authentication_providers);
+    $this->jsonApiBasePath = $jsonapi_base_path;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('jsonapi.resource_type.repository'),
+      $container->getParameter('authentication_providers'),
+      $container->getParameter('jsonapi.base_path')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function routes() {
+    $routes = new RouteCollection();
+
+    foreach ($this->resourceTypeRepository->all() as $resource_type) {
+      $routes->addCollection($this->getFileUploadRoutesForResourceType($resource_type));
+    }
+
+    // Enable all available authentication providers.
+    $routes->addOptions(['_auth' => $this->providerIds]);
+
+    // Flag every route as belonging to the JSON API module.
+    $routes->addDefaults([JsonApiRoutes::JSON_API_ROUTE_FLAG_KEY => TRUE]);
+
+    // Only accept 'Content-Type: application/octet-stream' requests.
+    $routes->addRequirements(['_content_type_format' => 'bin']);
+
+    // Resource routes all have the same controller.
+    $routes->addPrefix($this->jsonApiBasePath);
+
+    return $routes;
+  }
+
+  /**
+   * Gets the file upload route collection for the given resource type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type for which the route collection should be created.
+   *
+   * @return \Symfony\Component\Routing\RouteCollection
+   *   The route collection.
+   */
+  protected function getFileUploadRoutesForResourceType(ResourceType $resource_type) {
+    $routes = new RouteCollection();
+
+    // Internal resources have no routes; individual routes require locations.
+    if ($resource_type->isInternal() || !$resource_type->isLocatable()) {
+      return $routes;
+    }
+
+    if ($resource_type->isMutable()) {
+      $path = $resource_type->getPath();
+      $entity_type_id = $resource_type->getEntityTypeId();
+
+      $new_resource_file_upload_route = new Route("/{$path}/{file_field_name}");
+      $new_resource_file_upload_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi_file_upload.request_handler:handleFileUploadForNewResource']);
+      $new_resource_file_upload_route->setMethods(['POST']);
+      $new_resource_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $routes->add(static::getFileUploadRouteName($resource_type, 'new_resource'), $new_resource_file_upload_route);
+
+      $existing_resource_file_upload_route = new Route("/{$path}/{entity}/{file_field_name}");
+      $existing_resource_file_upload_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi_file_upload.request_handler:handleFileUploadForExistingResource']);
+      $existing_resource_file_upload_route->setMethods(['POST']);
+      $existing_resource_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE');
+      $routes->add(static::getFileUploadRouteName($resource_type, 'existing_resource'), $existing_resource_file_upload_route);
+
+      // Add entity parameter conversion to every route.
+      $routes->addOptions(['parameters' => ['entity' => ['type' => 'entity:' . $entity_type_id]]]);
+
+      // Add the resource type as a parameter to every resource route.
+      foreach ($routes as $route) {
+        static::addRouteParameter($route, JsonApiRoutes::RESOURCE_TYPE_KEY, ['type' => ResourceTypeConverter::PARAM_TYPE_ID]);
+        $route->addDefaults([JsonApiRoutes::RESOURCE_TYPE_KEY => $resource_type->getTypeName()]);
+      }
+
+    }
+
+    return $routes;
+  }
+
+  /**
+   * Adds a parameter option to a route, overrides options of the same name.
+   *
+   * The Symfony Route class only has a method for adding options which
+   * overrides any previous values. Therefore, it is tedious to add a single
+   * parameter while keeping those that are already set.
+   *
+   * @param \Symfony\Component\Routing\Route $route
+   *   The route to which the parameter is to be added.
+   * @param string $name
+   *   The name of the parameter.
+   * @param mixed $parameter
+   *   The parameter's options.
+   */
+  protected static function addRouteParameter(Route $route, $name, $parameter) {
+    $parameters = $route->getOption('parameters') ?: [];
+    $parameters[$name] = $parameter;
+    $route->setOption('parameters', $parameters);
+  }
+
+  /**
+   * Get a unique route name for the file upload resource type and route type.
+   *
+   * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
+   *   The resource type for which the route collection should be created.
+   * @param string $route_type
+   *   The route type. E.g. 'individual' or 'collection'.
+   *
+   * @return string
+   *   The generated route name.
+   */
+  protected static function getFileUploadRouteName(ResourceType $resource_type, $route_type) {
+    return sprintf('jsonapi.%s.%s.%s', $resource_type->getTypeName(), 'file_upload', $route_type);
+  }
+
+}
diff --git a/modules/jsonapi_file_upload/src/Shim/FileUploader.php b/modules/jsonapi_file_upload/src/Shim/FileUploader.php
new file mode 100644
index 0000000..f31eb10
--- /dev/null
+++ b/modules/jsonapi_file_upload/src/Shim/FileUploader.php
@@ -0,0 +1,450 @@
+<?php
+
+namespace Drupal\jsonapi_file_upload\Shim;
+
+use Drupal\Component\Utility\Bytes;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\file\FileInterface;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Lock\LockBackendInterface;
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Utility\Token;
+use Drupal\Component\Render\PlainTextOutput;
+use Drupal\file\Entity\File;
+use Drupal\jsonapi\Entity\EntityValidationTrait;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
+use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Reads data from an upload stream and creates a corresponding file entity.
+ *
+ * This is implemented at the field-level for the following reasons:
+ *   - Validation for uploaded files is tied to fields (allowed extensions, max
+ *     size, etc..).
+ *   - The actual files do not need to be stored in another temporary location,
+ *     to be later moved when they are referenced from a file field.
+ *   - Permission to upload a file can be determined by a user's field and
+ *     entity level access.
+ *
+ * @internal
+ */
+class FileUploader implements FileUploaderInterface {
+
+  use EntityValidationTrait {
+    validate as resourceValidate;
+  }
+
+  /**
+   * The regex used to extract the filename from the content disposition header.
+   *
+   * @var string
+   */
+  const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
+
+  /**
+   * The amount of bytes to read in each iteration when streaming file data.
+   *
+   * @var int
+   */
+  const BYTES_TO_READ = 8192;
+
+  /**
+   * A logger instance.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * The MIME type guesser.
+   *
+   * @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface
+   */
+  protected $mimeTypeGuesser;
+
+  /**
+   * The token replacement instance.
+   *
+   * @var \Drupal\Core\Utility\Token
+   */
+  protected $token;
+
+  /**
+   * The lock service.
+   *
+   * @var \Drupal\Core\Lock\LockBackendInterface
+   */
+  protected $lock;
+
+  /**
+   * System file configuration.
+   *
+   * @var \Drupal\Core\Config\ImmutableConfig
+   */
+  protected $systemFileConfig;
+
+  /**
+   * Constructs a FileUploadResource instance.
+   *
+   * @param \Psr\Log\LoggerInterface $logger
+   *   A logger instance.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system service.
+   * @param \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface $mime_type_guesser
+   *   The MIME type guesser.
+   * @param \Drupal\Core\Utility\Token $token
+   *   The token replacement instance.
+   * @param \Drupal\Core\Lock\LockBackendInterface $lock
+   *   The lock service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   */
+  public function __construct(LoggerInterface $logger, FileSystemInterface $file_system, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock, ConfigFactoryInterface $config_factory) {
+    $this->logger = $logger;
+    $this->fileSystem = $file_system;
+    $this->mimeTypeGuesser = $mime_type_guesser;
+    $this->token = $token;
+    $this->lock = $lock;
+    $this->systemFileConfig = $config_factory->get('system.file');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function handleFileUploadForField(FieldDefinitionInterface $field_definition, $filename, $stream, AccountInterface $owner) {
+    assert($field_definition->getType() === 'file');
+    $destination = $this->getUploadLocation($field_definition->getSettings());
+
+    // Check the destination file path is writable.
+    if (!file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) {
+      throw new HttpException(500, 'Destination file path is not writable');
+    }
+
+    $validators = $this->getUploadValidators($field_definition);
+
+    $prepared_filename = $this->prepareFilename($filename, $validators);
+
+    // Create the file.
+    $file_uri = "{$destination}/{$prepared_filename}";
+
+    $temp_file_path = $this->readStreamDataToFile($stream);
+
+    // This will take care of altering $file_uri if a file already exists.
+    file_unmanaged_prepare($temp_file_path, $file_uri);
+
+    // Lock based on the prepared file URI.
+    $lock_id = $this->generateLockIdFromFileUri($file_uri);
+
+    if (!$this->lock->acquire($lock_id)) {
+      throw new HttpException(503, sprintf('File "%s" is already locked for writing'), NULL, ['Retry-After' => 1]);
+    }
+
+    // Begin building file entity.
+    $file = File::create([]);
+    $file->setOwnerId($owner->id());
+    $file->setFilename($prepared_filename);
+    $file->setMimeType($this->mimeTypeGuesser->guess($prepared_filename));
+    $file->setFileUri($file_uri);
+    // Set the size. This is done in File::preSave() but we validate the file
+    // before it is saved.
+    $file->setSize(@filesize($temp_file_path));
+
+    // Validate the file entity against entity-level validation and field-level
+    // validators.
+    $this->validate($file, $validators);
+
+    // Move the file to the correct location after validation. Use
+    // FILE_EXISTS_ERROR as the file location has already been determined above
+    // in file_unmanaged_prepare().
+    if (!file_unmanaged_move($temp_file_path, $file_uri, FILE_EXISTS_ERROR)) {
+      throw new HttpException(500, 'Temporary file could not be moved to file location');
+    }
+
+    $file->save();
+
+    $this->lock->release($lock_id);
+
+    return $file;
+  }
+
+  /**
+   * Validates and extracts the filename from the Content-Disposition header.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object.
+   *
+   * @return string
+   *   The filename extracted from the header.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
+   *   Thrown when the 'Content-Disposition' request header is invalid.
+   */
+  public static function validateAndParseContentDispositionHeader(Request $request) {
+    // Firstly, check the header exists.
+    if (!$request->headers->has('content-disposition')) {
+      throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided');
+    }
+
+    $content_disposition = $request->headers->get('content-disposition');
+
+    // Parse the header value. This regex does not allow an empty filename.
+    // i.e. 'filename=""'. This also matches on a word boundary so other keys
+    // like 'not_a_filename' don't work.
+    if (!preg_match(static::REQUEST_HEADER_FILENAME_REGEX, $content_disposition, $matches)) {
+      throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided');
+    }
+
+    // Check for the "filename*" format. This is currently unsupported.
+    if (!empty($matches['star'])) {
+      throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header');
+    }
+
+    // Don't validate the actual filename here, that will be done by the upload
+    // validators in validate().
+    // @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validate()
+    $filename = $matches['filename'];
+
+    // Make sure only the filename component is returned. Path information is
+    // stripped as per https://tools.ietf.org/html/rfc6266#section-4.3.
+    return basename($filename);
+  }
+
+  /**
+   * Gets a standard file upload stream.
+   *
+   * @return resource
+   *   A stream ready to be read. Do not forget to close the stream after use
+   *   with fclose().
+   */
+  public static function getUploadStream() {
+    return fopen('php://input', 'rb');
+  }
+
+  /**
+   * Checks if the current user has access to upload the file.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The account for which file upload access should be checked.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition for which to get validators.
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   (optional) The entity to which the file is to be uploaded, if it exists.
+   *   If the entity does not exist and it is not given, create access to the
+   *   file will be checked.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The file upload access result.
+   */
+  public static function checkFileUploadAccess(AccountInterface $account, FieldDefinitionInterface $field_definition, EntityInterface $entity = NULL) {
+    assert(is_null($entity) || $field_definition->getTargetEntityTypeId() === $entity->getEntityTypeId() && $field_definition->getTargetBundle() === $entity->bundle());
+    $entity_type_manager = \Drupal::entityTypeManager();
+    $entity_access_control_handler = $entity_type_manager->getAccessControlHandler($field_definition->getTargetEntityTypeId());
+    $bundle = $entity_type_manager->getDefinition($field_definition->getTargetEntityTypeId())->hasKey('bundle') ? $field_definition->getTargetBundle() : NULL;
+    $entity_access_result = $entity
+      ? $entity_access_control_handler->access($entity, 'update', $account, TRUE)
+      : $entity_access_control_handler->createAccess($bundle, $account, [], TRUE);
+    $field_access_result = $entity_access_control_handler->fieldAccess('edit', $field_definition, NULL, NULL, TRUE);
+    return $entity_access_result->andIf($field_access_result);
+  }
+
+  /**
+   * Reads file upload data to temporary file and moves to file destination.
+   *
+   * Note that the given stream will not be closed by this method.
+   *
+   * @param resource $stream
+   *   The stream on the file.
+   *
+   * @return string
+   *   The temp file path.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
+   *   Thrown when input data cannot be read, the temporary file cannot be
+   *   opened, or the temporary file cannot be written.
+   */
+  protected function readStreamDataToFile($stream) {
+    $temp_file_path = $this->fileSystem->tempnam('temporary://', 'file');
+    $temp_file = fopen($temp_file_path, 'wb');
+
+    if ($temp_file) {
+      while (!feof($stream)) {
+        $read = fread($stream, static::BYTES_TO_READ);
+
+        if ($read === FALSE) {
+          // Close the file streams.
+          fclose($temp_file);
+          $this->logger->error('Input data could not be read');
+          throw new HttpException(500, 'Input file data could not be read');
+        }
+
+        if (fwrite($temp_file, $read) === FALSE) {
+          // Close the file streams.
+          fclose($temp_file);
+          $this->logger->error('Temporary file data for "%path" could not be written', ['%path' => $temp_file_path]);
+          throw new HttpException(500, 'Temporary file data could not be written');
+        }
+      }
+
+      // Close the temp file stream.
+      fclose($temp_file);
+    }
+    else {
+      // Close the file streams.
+      fclose($temp_file);
+      $this->logger->error('Temporary file "%path" could not be opened for file upload', ['%path' => $temp_file_path]);
+      throw new HttpException(500, 'Temporary file could not be opened');
+    }
+
+    return $temp_file_path;
+  }
+
+  /**
+   * Validates the file.
+   *
+   * @param \Drupal\file\FileInterface $file
+   *   The file entity to validate.
+   * @param array $validators
+   *   An array of upload validators to pass to file_validate().
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
+   *   Thrown when there are file validation errors.
+   */
+  protected function validate(FileInterface $file, array $validators) {
+    static::resourceValidate($file);
+
+    // Validate the file based on the field definition configuration.
+    $errors = file_validate($file, $validators);
+
+    if (!empty($errors)) {
+      $message = "Unprocessable Entity: file validation failed.\n";
+      $message .= implode("\n", array_map(function ($error) {
+        return PlainTextOutput::renderFromHtml($error);
+      }, $errors));
+
+      throw new UnprocessableEntityHttpException($message);
+    }
+  }
+
+  /**
+   * Prepares the filename to strip out any malicious extensions.
+   *
+   * @param string $filename
+   *   The file name.
+   * @param array $validators
+   *   The array of upload validators.
+   *
+   * @return string
+   *   The prepared/munged filename.
+   */
+  protected function prepareFilename($filename, array &$validators) {
+    if (!empty($validators['file_validate_extensions'][0])) {
+      // If there is a file_validate_extensions validator and a list of
+      // valid extensions, munge the filename to protect against possible
+      // malicious extension hiding within an unknown file type. For example,
+      // "filename.html.foo".
+      $filename = file_munge_filename($filename, $validators['file_validate_extensions'][0]);
+    }
+
+    // Rename potentially executable files, to help prevent exploits (i.e. will
+    // rename filename.php.foo and filename.php to filename.php.foo.txt and
+    // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads'
+    // evaluates to TRUE.
+    if (!$this->systemFileConfig->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $filename) && (substr($filename, -4) != '.txt')) {
+      // The destination filename will also later be used to create the URI.
+      $filename .= '.txt';
+
+      // The .txt extension may not be in the allowed list of extensions. We
+      // have to add it here or else the file upload will fail.
+      if (!empty($validators['file_validate_extensions'][0])) {
+        $validators['file_validate_extensions'][0] .= ' txt';
+      }
+    }
+
+    return $filename;
+  }
+
+  /**
+   * Determines the URI for a file field.
+   *
+   * @param array $settings
+   *   The array of field settings.
+   *
+   * @return string
+   *   An un-sanitized file directory URI with tokens replaced. The result of
+   *   the token replacement is then converted to plain text and returned.
+   */
+  protected function getUploadLocation(array $settings) {
+    $destination = trim($settings['file_directory'], '/');
+
+    // Replace tokens. As the tokens might contain HTML we convert it to plain
+    // text.
+    $destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, [], [], new BubbleableMetadata()));
+    return $settings['uri_scheme'] . '://' . $destination;
+  }
+
+  /**
+   * Retrieves the upload validators for a field definition.
+   *
+   * This is copied from \Drupal\file\Plugin\Field\FieldType\FileItem as there
+   * is no entity instance available here that that a FileItem would exist for.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition for which to get validators.
+   *
+   * @return array
+   *   An array suitable for passing to file_save_upload() or the file field
+   *   element's '#upload_validators' property.
+   */
+  protected function getUploadValidators(FieldDefinitionInterface $field_definition) {
+    $validators = [
+      // Add in our check of the file name length.
+      'file_validate_name_length' => [],
+    ];
+    $settings = $field_definition->getSettings();
+
+    // Cap the upload size according to the PHP limit.
+    $max_filesize = Bytes::toInt(file_upload_max_size());
+    if (!empty($settings['max_filesize'])) {
+      $max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize']));
+    }
+
+    // There is always a file size limit due to the PHP server limit.
+    $validators['file_validate_size'] = [$max_filesize];
+
+    // Add the extension check if necessary.
+    if (!empty($settings['file_extensions'])) {
+      $validators['file_validate_extensions'] = [$settings['file_extensions']];
+    }
+
+    return $validators;
+  }
+
+  /**
+   * Generates a lock ID based on the file URI.
+   *
+   * @param string $file_uri
+   *   The file URI.
+   *
+   * @return string
+   *   The generated lock ID.
+   */
+  protected static function generateLockIdFromFileUri($file_uri) {
+    return 'file:rest:' . Crypt::hashBase64($file_uri);
+  }
+
+}
diff --git a/modules/jsonapi_file_upload/src/Shim/FileUploaderInterface.php b/modules/jsonapi_file_upload/src/Shim/FileUploaderInterface.php
new file mode 100644
index 0000000..4854f58
--- /dev/null
+++ b/modules/jsonapi_file_upload/src/Shim/FileUploaderInterface.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\jsonapi_file_upload\Shim;
+
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Interface to handle binary file data uploads and file entity creation.
+ *
+ * This is implemented at the field-level for the following reasons:
+ *   - Validation for uploaded files is tied to fields (allowed extensions, max
+ *     size, etc..).
+ *   - The actual files do not need to be stored in another temporary location,
+ *     to be later moved when they are referenced from a file field.
+ *   - Permission to upload a file can be determined by a users field level
+ *     create access to the file field.
+ *
+ * @internal
+ */
+interface FileUploaderInterface {
+
+  /**
+   * Creates and validates a file entity for a file field from a file stream.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The field definition of the field for which the file is to be uploaded.
+   * @param string $filename
+   *   The name of the file.
+   * @param resource $stream
+   *   The upload stream.
+   * @param \Drupal\Core\Session\AccountInterface $owner
+   *   The owner of the file. Defaults to the current user. Note, it is the
+   *   responsibility of the caller to enforce access.
+   *
+   * @return \Drupal\file\FileInterface
+   *   The newly uploaded file entity.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
+   *   Thrown when temporary files cannot be written, a lock cannot be acquired,
+   *   or when temporary files cannot be moved to their new location.
+   */
+  public function handleFileUploadForField(FieldDefinitionInterface $field_definition, $filename, $stream, AccountInterface $owner);
+
+}
diff --git a/src/Controller/EntityResource.php b/src/Controller/EntityResource.php
index 3b365e5..1f70235 100644
--- a/src/Controller/EntityResource.php
+++ b/src/Controller/EntityResource.php
@@ -16,15 +16,15 @@ use Drupal\Core\Field\FieldItemListInterface;
 use Drupal\Core\Field\FieldTypePluginManagerInterface;
 use Drupal\Core\Render\RenderContext;
 use Drupal\Core\Render\RendererInterface;
+use Drupal\jsonapi\Entity\EntityValidationTrait;
 use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
-use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+use Drupal\jsonapi\JsonApiResource\EntityCollection;
+use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
 use Drupal\jsonapi\LabelOnlyEntity;
+use Drupal\jsonapi\LinkManager\LinkManager;
 use Drupal\jsonapi\Query\Filter;
-use Drupal\jsonapi\Query\Sort;
 use Drupal\jsonapi\Query\OffsetPage;
-use Drupal\jsonapi\LinkManager\LinkManager;
-use Drupal\jsonapi\JsonApiResource\EntityCollection;
-use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
+use Drupal\jsonapi\Query\Sort;
 use Drupal\jsonapi\ResourceResponse;
 use Drupal\jsonapi\ResourceType\ResourceType;
 use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
@@ -41,6 +41,8 @@ use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
  */
 class EntityResource {
 
+  use EntityValidationTrait;
+
   /**
    * The JSON API resource type.
    *
@@ -151,51 +153,6 @@ class EntityResource {
     return $response;
   }
 
-  /**
-   * Verifies that the whole entity does not violate any validation constraints.
-   *
-   * @param \Drupal\Core\Entity\EntityInterface $entity
-   *   The entity object.
-   * @param string[] $field_names
-   *   (optional) An array of field names. If specified, filters the violations
-   *   list to include only this set of fields. Defaults to NULL,
-   *   which means that all violations will be reported.
-   *
-   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
-   *   Thrown when violations remain after filtering.
-   *
-   * @see \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait::validate()
-   */
-  protected function validate(EntityInterface $entity, array $field_names = NULL) {
-    if (!$entity instanceof FieldableEntityInterface) {
-      return;
-    }
-
-    $violations = $entity->validate();
-
-    // Remove violations of inaccessible fields as they cannot stem from our
-    // changes.
-    $violations->filterByFieldAccess();
-
-    // Filter violations based on the given fields.
-    if ($field_names !== NULL) {
-      $violations->filterByFields(
-        array_diff(array_keys($entity->getFieldDefinitions()), $field_names)
-      );
-    }
-
-    if (count($violations) > 0) {
-      // Instead of returning a generic 400 response we use the more specific
-      // 422 Unprocessable Entity code from RFC 4918. That way clients can
-      // distinguish between general syntax errors in bad serializations (code
-      // 400) and semantic errors in well-formed requests (code 422).
-      // @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer
-      $exception = new UnprocessableHttpEntityException();
-      $exception->setViolations($violations);
-      throw $exception;
-    }
-  }
-
   /**
    * Creates an individual entity.
    *
@@ -209,6 +166,8 @@ class EntityResource {
    *
    * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException
    *   Thrown when the entity already exists.
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when the entity does not pass validation.
    */
   public function createIndividual(EntityInterface $entity, Request $request) {
     if ($entity instanceof FieldableEntityInterface) {
@@ -239,7 +198,7 @@ class EntityResource {
       }
     }
 
-    $this->validate($entity);
+    static::validate($entity);
 
     // Return a 409 Conflict response in accordance with the JSON API spec. See
     // http://jsonapi.org/format/#crud-creating-responses-409.
@@ -283,6 +242,8 @@ class EntityResource {
    *
    * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
    *   Thrown when the selected entity does not match the id in th payload.
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when the patched entity does not pass validation.
    */
   public function patchIndividual(EntityInterface $entity, EntityInterface $parsed_entity, Request $request) {
     $body = Json::decode($request->getContent());
@@ -302,7 +263,7 @@ class EntityResource {
       return $destination;
     }, $entity);
 
-    $this->validate($entity, $field_names);
+    static::validate($entity, $field_names);
     $entity->save();
     return $this->buildWrappedResponse($entity);
   }
@@ -510,6 +471,8 @@ class EntityResource {
    *   field(s).
    * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException
    *   Thrown when POSTing to a "to-one" relationship.
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when the updated entity does not pass validation.
    */
   public function createRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request) {
     $related_field = $this->resourceType->getInternalName($related_field);
@@ -535,7 +498,7 @@ class EntityResource {
     foreach ($parsed_field_list as $field_item) {
       $field_list->appendItem($field_item->getValue());
     }
-    $this->validate($entity);
+    static::validate($entity);
     $entity->save();
     $status = static::relationshipArityIsAffected($original_field_list, $field_list)
       ? 200
@@ -600,6 +563,9 @@ class EntityResource {
    *
    * @return \Drupal\jsonapi\ResourceResponse
    *   The response.
+   *
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when the updated entity does not pass validation.
    */
   public function patchRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request) {
     $related_field = $this->resourceType->getInternalName($related_field);
@@ -618,7 +584,7 @@ class EntityResource {
       ->isMultiple();
     $method = $is_multiple ? 'doPatchMultipleRelationship' : 'doPatchIndividualRelationship';
     $this->{$method}($entity, $parsed_field_list);
-    $this->validate($entity);
+    static::validate($entity);
     $entity->save();
     return $this->getRelationship($entity, $related_field, $request, 204);
   }
@@ -714,7 +680,7 @@ class EntityResource {
       ->createFieldItemList($entity, $related_field, $keep_values);
 
     // Save the entity and return the response object.
-    $this->validate($entity);
+    static::validate($entity);
     $entity->save();
     return $this->getRelationship($entity, $related_field, $request, 204);
   }
diff --git a/src/Entity/EntityValidationTrait.php b/src/Entity/EntityValidationTrait.php
new file mode 100644
index 0000000..86799d7
--- /dev/null
+++ b/src/Entity/EntityValidationTrait.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Drupal\jsonapi\Entity;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
+
+/**
+ * Provides a method to validate an entity.
+ *
+ * @package Drupal\jsonapi\Entity
+ */
+trait EntityValidationTrait {
+
+  /**
+   * Verifies that an entity does not violate any validation constraints.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity object.
+   * @param string[] $field_names
+   *   (optional) An array of field names. If specified, filters the violations
+   *   list to include only this set of fields. Defaults to NULL,
+   *   which means that all violations will be reported.
+   *
+   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
+   *   Thrown when violations remain after filtering.
+   *
+   * @see \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait::validate()
+   */
+  protected static function validate(EntityInterface $entity, array $field_names = NULL) {
+    if (!$entity instanceof FieldableEntityInterface) {
+      return;
+    }
+
+    $violations = $entity->validate();
+
+    // Remove violations of inaccessible fields as they cannot stem from our
+    // changes.
+    $violations->filterByFieldAccess();
+
+    // Filter violations based on the given fields.
+    if ($field_names !== NULL) {
+      $violations->filterByFields(
+        array_diff(array_keys($entity->getFieldDefinitions()), $field_names)
+      );
+    }
+
+    if (count($violations) > 0) {
+      // Instead of returning a generic 400 response we use the more specific
+      // 422 Unprocessable Entity code from RFC 4918. That way clients can
+      // distinguish between general syntax errors in bad serializations (code
+      // 400) and semantic errors in well-formed requests (code 422).
+      // @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer
+      $exception = new UnprocessableHttpEntityException();
+      $exception->setViolations($violations);
+      throw $exception;
+    }
+  }
+
+}
