diff --git a/commerce_license.module b/commerce_license.module
index 6525877..999aa32 100644
--- a/commerce_license.module
+++ b/commerce_license.module
@@ -5,6 +5,7 @@
  * Contains commerce_license.module.
  */
 
+use Drupal\commerce_license\Entity\License;
 use Drupal\Core\Entity\EntityFormInterface;
 use Drupal\user\EntityOwnerInterface;
 use Drupal\Core\Form\FormStateInterface;
@@ -266,6 +267,35 @@ function commerce_license_field_widget_form_alter(&$element, FormStateInterface
 }
 
 /**
+ * Implements hook_cron().
+ */
+function commerce_license_cron() {
+  $queue = \Drupal::queue('commerce_license_expire');
+  $commerce_license_storage = \Drupal::entityTypeManager()->getStorage('commerce_license');
+
+  $license_ids = $commerce_license_storage->getLicenseIdsToExpire();
+
+  // Use the time the query was run, rather than the request time, as this is
+  // more accurate in cron runs.
+  $query_time = \Drupal::time()->getCurrentTime();
+
+  foreach ($commerce_license_storage->loadMultiple($license_ids) as $license) {
+    $item = [
+      'license_id' => $license->id(),
+      // Queue workers don't get the time the item was queued, and we need ours
+      // to know when we ran the query.
+      'queued_time' => $query_time,
+    ];
+
+    if ($queue->createItem($item)) {
+      // Add timestamp to avoid queueing item more than once.
+      $license->setQueuedTime($query_time);
+      $license->save();
+    }
+  }
+}
+
+/**
  * Implements hook_theme().
  */
 function commerce_license_theme() {
diff --git a/src/Entity/License.php b/src/Entity/License.php
index 8746347..f585c02 100644
--- a/src/Entity/License.php
+++ b/src/Entity/License.php
@@ -162,6 +162,21 @@ class License extends ContentEntityBase implements LicenseInterface {
   /**
    * {@inheritdoc}
    */
+  public function getQueuedTime() {
+    return $this->get('queued')->value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setQueuedTime($timestamp) {
+    $this->set('queued', $timestamp);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public function getCreatedTime() {
     return $this->get('created')->value;
   }
@@ -286,13 +301,6 @@ class License extends ContentEntityBase implements LicenseInterface {
       ->setDisplayConfigurable('view', TRUE)
       ->setSetting('workflow_callback', ['\Drupal\commerce_license\Entity\License', 'getWorkflowId']);
 
-    $fields['queues'] = BaseFieldDefinition::create('string')
-      ->setLabel(t('Queues'))
-      ->setDescription(t('The queues in which this license is currently placed.'))
-      ->setCardinality(BaseFieldDefinition::CARDINALITY_UNLIMITED)
-      ->setDisplayConfigurable('form', FALSE)
-      ->setDisplayConfigurable('view', FALSE);
-
     $fields['product'] = BaseFieldDefinition::create('entity_reference')
       ->setLabel(t('Licensed product'))
       ->setDescription(t('The licensed product.'))
@@ -359,6 +367,11 @@ class License extends ContentEntityBase implements LicenseInterface {
       ->setDisplayConfigurable('view', TRUE)
       ->setDefaultValue(0);
 
+    $fields['queued'] = BaseFieldDefinition::create('timestamp')
+      ->setLabel(t('Queued'))
+      ->setDescription(t('The time that the license was placed into the queue for expiry.'))
+      ->setDefaultValue(NULL);
+
     return $fields;
   }
 
diff --git a/src/Entity/LicenseInterface.php b/src/Entity/LicenseInterface.php
index aedd57b..0a40388 100644
--- a/src/Entity/LicenseInterface.php
+++ b/src/Entity/LicenseInterface.php
@@ -52,6 +52,25 @@ interface LicenseInterface extends EntityChangedInterface, EntityOwnerInterface
   public function setExpiresTime($timestamp);
 
   /**
+   * Gets the timestamp for when the License expiry was queued.
+   *
+   * @return int
+   *   Timestamp the Licence was queued for expiry.
+   */
+  public function getQueuedTime();
+
+  /**
+   * Sets the time at which the License was placed into the expiry queue.
+   *
+   * @param int $timestamp
+   *   Timestamp the Licence is queued for expiry.
+   *
+   * @return \Drupal\commerce_license\Entity\LicenseInterface
+   *   The called License entity.
+   */
+  public function setQueuedTime($timestamp);
+
+  /**
    * Get an unconfigured instance of the associated license type plugin.
    *
    * @return \Drupal\commerce_license\Plugin\Commerce\LicenseType\LicenseTypeInterface
diff --git a/src/LicenseStorage.php b/src/LicenseStorage.php
index 5baa354..80b787d 100644
--- a/src/LicenseStorage.php
+++ b/src/LicenseStorage.php
@@ -2,11 +2,17 @@
 
 namespace Drupal\commerce_license;
 
-use Drupal\commerce\CommerceContentEntityStorage;
-use Drupal\Core\Session\AccountInterface;
-use Drupal\Core\Language\LanguageInterface;
 use Drupal\commerce_order\Entity\OrderItemInterface;
-use Drupal\commerce_license\Entity\LicenseInterface;
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Language\LanguageManagerInterface;
+use Drupal\commerce\CommerceContentEntityStorage;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
 
 /**
  * Defines the storage handler class for License entities.
@@ -19,6 +25,60 @@ use Drupal\commerce_license\Entity\LicenseInterface;
 class LicenseStorage extends CommerceContentEntityStorage implements LicenseStorageInterface {
 
   /**
+   * The time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
+  /**
+   * Constructs a new LicenseStorage object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param \Drupal\Core\Database\Connection $database
+   *   The database connection to be used.
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
+   *   The cache backend to be used.
+   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
+   *   The language manager.
+   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
+   *   The event dispatcher.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   */
+  public function __construct(
+    EntityTypeInterface $entity_type,
+    Connection $database,
+    EntityManagerInterface $entity_manager,
+    CacheBackendInterface $cache,
+    LanguageManagerInterface $language_manager,
+    EventDispatcherInterface $event_dispatcher,
+    TimeInterface $time
+  ) {
+    parent::__construct($entity_type, $database, $entity_manager, $cache, $language_manager, $event_dispatcher, $time);
+
+    $this->time = $time;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+    return new static(
+      $entity_type,
+      $container->get('database'),
+      $container->get('entity.manager'),
+      $container->get('cache.entity'),
+      $container->get('language_manager'),
+      $container->get('event_dispatcher'),
+      $container->get('datetime.time')
+    );
+  }
+
+  /**
    * {@inheritdoc}
    */
   public function createFromOrderItem(OrderItemInterface $order_item) {
@@ -46,4 +106,21 @@ class LicenseStorage extends CommerceContentEntityStorage implements LicenseStor
     return $license;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getLicenseIdsToExpire() {
+    return $this->database->query('SELECT license_id FROM {commerce_license}
+      WHERE
+      state = :active
+      AND expires <= :time
+      AND expires <> :never
+      AND queued IS NULL',
+      [
+      ':active' => 'active',
+      ':time' => $this->time->getRequestTime(),
+      ':never' => self::EXPIRE_NEVER,
+    ])->fetchCol();
+  }
+
 }
diff --git a/src/LicenseStorageInterface.php b/src/LicenseStorageInterface.php
index c15225f..06ac914 100644
--- a/src/LicenseStorageInterface.php
+++ b/src/LicenseStorageInterface.php
@@ -19,6 +19,11 @@ use Drupal\commerce_license\Entity\LicenseInterface;
 interface LicenseStorageInterface extends ContentEntityStorageInterface {
 
   /**
+   * Denotes that a license should never expire.
+   */
+  const EXPIRE_NEVER = 0;
+
+  /**
    * Creates a new license from an order item.
    *
    * @param \Drupal\commerce_order\Entity\OrderItemInterface $order_item
@@ -29,4 +34,14 @@ interface LicenseStorageInterface extends ContentEntityStorageInterface {
    */
   public function createFromOrderItem(OrderItemInterface $order_item);
 
+  /**
+   * Returns the ids of licenses that need to be expired.
+   *
+   * This omits licenses which have a timestamp set in their 'queued' field.
+   *
+   * @return array
+   *   A list of license ids to be expired.
+   */
+  public function getLicenseIdsToExpire();
+
 }
diff --git a/src/Plugin/QueueWorker/LicenseExpiry.php b/src/Plugin/QueueWorker/LicenseExpiry.php
new file mode 100644
index 0000000..d96b70b
--- /dev/null
+++ b/src/Plugin/QueueWorker/LicenseExpiry.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\commerce_license\Plugin\QueueWorker;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Queue\QueueWorkerBase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * @QueueWorker(
+ *   id = "commerce_license_expire",
+ *   title = @Translation("Expires licenses"),
+ *   cron = {"time" = 15}
+ * )
+ */
+class LicenseExpiry extends QueueWorkerBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Creates a LicenseExpiry instance.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function processItem($data) {
+    $license = $this->entityTypeManager->getStorage('commerce_license')->load($data['license_id']);
+
+    // Check that the license has not been renewed since the time that it was
+    // placed in the queue. If it's been renewed already, do nothing.
+    if ($license->granted->value > $license->getQueuedTime()) {
+      return;
+    }
+
+    // Check the license is still active. If not, do nothing.
+    if ($license->state->value != 'active') {
+      return;
+    }
+
+    // Change the license's state to expired.
+    // The License class will handle deactivating the license type plugin.
+    $transition = $license->getState()->getWorkflow()->getTransition('expire');
+    $license->getState()->applyTransition($transition);
+    $license->save();
+  }
+
+}
diff --git a/tests/src/Kernel/LicenseCronExpiryTest.php b/tests/src/Kernel/LicenseCronExpiryTest.php
new file mode 100644
index 0000000..5db94e4
--- /dev/null
+++ b/tests/src/Kernel/LicenseCronExpiryTest.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Drupal\Tests\commerce_license\Kernel\System;
+
+use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
+use Drupal\Component\Datetime\TimeInterface;
+
+/**
+ * Tests that cron expires a license.
+ *
+ * @group commerce_license
+ */
+class LicenseCronExpiryTest extends EntityKernelTestBase {
+
+  /**
+   * A mocked timestamp for the first cron run.
+   */
+  const TIME_CRON_ONE = 1234000500;
+
+  /**
+   * A timestamp for the license's expiration.
+   *
+   * This is later than the first cron time, but earlier than the second cron
+   * time.
+   */
+  const TIME_EXPIRY = 1234000500 + 100;
+
+  /**
+   * A mocked timestamp for the second cron run.
+   */
+  const TIME_CRON_TWO = 1234000500 + 200;
+
+  /**
+   * The modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'system',
+    'user',
+    'state_machine',
+    'commerce',
+    'commerce_price',
+    'commerce_product',
+    'commerce_license',
+    'commerce_license_simple_type',
+    'recurring_period',
+  ];
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The cron service.
+   *
+   * @var \Drupal\Core\Cron
+   */
+  protected $cron;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // These additional tables are necessary because $this->cron->run() calls
+    // system_cron().
+    $this->installSchema('system', ['key_value_expire']);
+
+    $this->installEntitySchema('commerce_product_variation');
+    $this->installEntitySchema('commerce_license');
+    $this->installEntitySchema('user');
+    $this->installConfig('user');
+
+    $this->cron = \Drupal::service('cron');
+    $this->entityTypeManager = \Drupal::service('entity_type.manager');
+  }
+
+  /**
+   * Tests that a cron run expires a license.
+   */
+  public function testLicenseCronExpiry() {
+    // Mock the current time to begin with, so that timestamps set automatically
+    // on the license entity are consistent.
+    // (Note though that the 'changed' timestamp will not get set with the
+    // mocked value due to this core bug: https://www.drupal.org/node/2902896.)
+    $this->container->set('datetime.time', $this->getMock(TimeInterface::class));
+    $this->container->get('datetime.time')
+      ->method('getRequestTime')
+      ->willReturn(self::TIME_CRON_ONE);
+    $this->container->get('datetime.time')
+      ->method('getCurrentTime')
+      ->willReturn(self::TIME_CRON_ONE);
+
+    // Clear the entity handlers cached in the entity type manager, so that the
+    // license storage handler gets re-instantiated with the mocked service
+    // injected.
+    // (This clears all the entity type definitions too, which we don't need;
+    // see https://www.drupal.org/node/2902487.)
+    $this->entityTypeManager->clearCachedDefinitions();
+    // Get the fresh license storage.
+    $license_storage = $this->entityTypeManager->getStorage('commerce_license');
+
+    $license_owner = $this->createUser();
+
+    // Create a license in the 'active' state.
+    $license = $license_storage->create([
+      'type' => 'simple',
+      'state' => 'active',
+      'product' => 1,
+      'uid' => $license_owner->id(),
+      // Use the unlimited expiry plugin as it's simple.
+      'expiration_type' => [
+        'target_plugin_id' => 'unlimited',
+        'target_plugin_configuration' => [],
+      ],
+    ]);
+    $license->save();
+
+    // Force the expiration timestamp.
+    // As the state is not being changed, the expiration plugin won't be called.
+    // @todo: consider adding a expiration plugin for testing that leaves the set
+    // expiration value alone, or takes it from state.
+    $license->expires = self::TIME_EXPIRY;
+    $license->save();
+
+    // Run cron at a time prior to the expiration.
+
+    // Clear the entity handlers cached in the entity type manager, so that the
+    // license storage handler gets re-instantiated with the mocked service
+    // injected.
+    // (This clears all the entity type definitions too, which we don't need;
+    // see https://www.drupal.org/node/2902487.)
+    $this->entityTypeManager->clearCachedDefinitions();
+    // Get the fresh license storage.
+    $license_storage = $this->entityTypeManager->getStorage('commerce_license');
+
+    $expire_ids = $license_storage->getLicenseIdsToExpire();
+    $this->assertEquals([], $expire_ids, "The license ID is not returned by the expiration query.");
+
+    $this->cron->run();
+
+    // Check the license has not been changed.
+    $this->assertEquals('active', $license->state->value, "The license has not been changed and is still active.");
+
+    $queue = $this->container->get('queue')->get('commerce_license_expire');
+    $this->assertEquals(0, $queue->numberOfItems(), 'The license item was not added to the queue.');
+
+    // Run cron at a time after the expiration.
+    $this->container->set('datetime.time', $this->getMock(TimeInterface::class));
+    $this->container->get('datetime.time')
+      ->method('getRequestTime')
+      ->willReturn(self::TIME_CRON_TWO);
+    $this->container->get('datetime.time')
+      ->method('getCurrentTime')
+      ->willReturn(self::TIME_CRON_TWO);
+
+    // Clear the entity handlers cached in the entity type manager.
+    $this->entityTypeManager->clearCachedDefinitions();
+    // Get the fresh license storage.
+    $license_storage = $this->entityTypeManager->getStorage('commerce_license');
+
+    $expire_ids = $license_storage->getLicenseIdsToExpire();
+    $this->assertEquals([$license->id()], $expire_ids, "The license ID is returned by the expiration query.");
+
+    // Note that we can't test that we get added to the queue, as system_cron()
+    // runs after our hook_cron() implementation, and so will handle the queued
+    // item before we can inspect it.
+
+    $this->cron->run();
+
+    // Reload the license.
+    $license = $license_storage->load($license->id());
+
+    $this->assertEquals('expired', $license->state->value, "The license is now expired.");
+
+    // Note that we don't need to check that the expiry did something, as that
+    // is covered by LicenseStateChangeTest.
+  }
+
+}
