diff --git a/commerce_license.info.yml b/commerce_license.info.yml
index 1922a72..fb76dec 100644
--- a/commerce_license.info.yml
+++ b/commerce_license.info.yml
@@ -11,6 +11,7 @@ dependencies:
   - commerce:commerce_product
   - state_machine
   - recurring_period
+  - advancedqueue
 test_dependencies:
   - advancedqueue
   - commerce_recurring
diff --git a/commerce_license.module b/commerce_license.module
index d27ae14..d34099f 100644
--- a/commerce_license.module
+++ b/commerce_license.module
@@ -168,3 +168,10 @@ function commerce_licence_get_user_timezone(AccountInterface $user) {
     return !empty($config_data_default_timezone) ? $config_data_default_timezone : @date_default_timezone_get();
   }
 }
+
+/**
+ * Implements hook_cron().
+ */
+function commerce_license_cron() {
+  \Drupal::service('commerce_license.cron')->run();
+}
diff --git a/commerce_license.services.yml b/commerce_license.services.yml
index 8f30be3..f80f667 100644
--- a/commerce_license.services.yml
+++ b/commerce_license.services.yml
@@ -33,3 +33,7 @@ services:
       - '@entity_type.manager'
     tags:
       - { name: commerce.availability_checker }
+
+  commerce_license.cron:
+    class: \Drupal\commerce_license\Cron
+    arguments: ['@entity_type.manager', '@datetime.time']
diff --git a/composer.json b/composer.json
index d5bb198..18e7932 100644
--- a/composer.json
+++ b/composer.json
@@ -10,5 +10,7 @@
     "issues": "https://www.drupal.org/project/issues/commerce_license",
     "source": "http://cgit.drupalcode.org/commerce_license"
   },
-  "require": { }
+  "require": {
+    "drupal/advancedqueue": "^1.0"
+  }
 }
diff --git a/config/install/advancedqueue.advancedqueue_queue.commerce_license.yml b/config/install/advancedqueue.advancedqueue_queue.commerce_license.yml
new file mode 100644
index 0000000..9517cf3
--- /dev/null
+++ b/config/install/advancedqueue.advancedqueue_queue.commerce_license.yml
@@ -0,0 +1,7 @@
+id: commerce_license
+label: 'Commerce License'
+backend: database
+backend_configuration: {}
+processor: cron
+processing_time: 180
+locked: true
diff --git a/src/Cron.php b/src/Cron.php
new file mode 100644
index 0000000..6f0767f
--- /dev/null
+++ b/src/Cron.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\commerce_license;
+
+use Drupal\advancedqueue\Job;
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\CronInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Default cron implementation.
+ */
+class Cron implements CronInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The license storage.
+   *
+   * @var \Drupal\commerce_license\LicenseStorageInterface
+   */
+  protected $licenseStorage;
+
+  /**
+   * The time.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
+  /**
+   * Constructs a new Cron object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, TimeInterface $time) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->licenseStorage = $entity_type_manager->getStorage('commerce_license');
+    $this->time = $time;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('datetime.time')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function run() {
+    $time = $this->time->getRequestTime();
+    $license_ids = $this->licenseStorage->getLicenseIdsToExpire($time);
+    if ($license_ids) {
+      $queue_storage = $this->entityTypeManager->getStorage('advancedqueue_queue');
+      /** @var \Drupal\advancedqueue\Entity\QueueInterface $queue */
+      $queue = $queue_storage->load('commerce_license');
+      foreach ($license_ids as $license_id) {
+        // Create a job and queue each one up.
+        $expire_remove_roles_job = Job::create('commerce_license_expire_expired_license', [
+          'license_id' => $license_id,
+        ]);
+        $queue->enqueueJob($expire_remove_roles_job);
+      }
+    }
+  }
+
+}
diff --git a/src/LicenseStorage.php b/src/LicenseStorage.php
index 87a8778..c037e8f 100644
--- a/src/LicenseStorage.php
+++ b/src/LicenseStorage.php
@@ -58,4 +58,19 @@ class LicenseStorage extends CommerceContentEntityStorage implements LicenseStor
     return $license;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getLicenseIdsToExpire($time) {
+    // Get all of the active expired licenses.
+    // Ones we've already revoked will be marked expired.
+    $query = \Drupal::entityQuery('commerce_license')
+      ->condition('state', 'active')
+      ->condition('expires', $time, '<=')
+      ->condition('expires', 0, '<>');
+
+    $license_ids = $query->execute();
+    return $license_ids;
+  }
+
 }
diff --git a/src/LicenseStorageInterface.php b/src/LicenseStorageInterface.php
index 88def38..ae74c2b 100644
--- a/src/LicenseStorageInterface.php
+++ b/src/LicenseStorageInterface.php
@@ -46,4 +46,15 @@ interface LicenseStorageInterface extends ContentEntityStorageInterface {
    */
   public function createFromProductVariation(ProductVariationInterface $variation, $uid);
 
+  /**
+   * Returns the ids of licenses that need to be expired.
+   *
+   * @param int $time
+   *   The timestamp to be used for expiry check.
+   *
+   * @return array
+   *   A list of license ids to be expired.
+   */
+  public function getLicenseIdsToExpire($time);
+
 }
diff --git a/src/Plugin/AdvancedQueue/JobType/ExpireExpiredLicenses.php b/src/Plugin/AdvancedQueue/JobType/ExpireExpiredLicenses.php
new file mode 100644
index 0000000..8509a67
--- /dev/null
+++ b/src/Plugin/AdvancedQueue/JobType/ExpireExpiredLicenses.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Drupal\commerce_license\Plugin\AdvancedQueue\JobType;
+
+use Drupal\advancedqueue\Job;
+use Drupal\advancedqueue\JobResult;
+use Drupal\advancedqueue\Plugin\AdvancedQueue\JobType\JobTypeBase;
+use Drupal\commerce_payment\Exception\DeclineException;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * Provides the job type for expiring expired licenses.
+ *
+ * @AdvancedQueueJobType(
+ *   id = "commerce_license_expire_expired_license",
+ *   label = @Translation("Expire expired licenses."),
+ * )
+ */
+class ExpireExpiredLicenses extends JobTypeBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The event dispatcher.
+   *
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   */
+  protected $eventDispatcher;
+
+  /**
+   * Constructs a new ExpireExpiredLicenses object.
+   *
+   * @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.
+   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
+   *   The event dispatcher.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EventDispatcherInterface $event_dispatcher) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+    $this->entityTypeManager = $entity_type_manager;
+    $this->eventDispatcher = $event_dispatcher;
+  }
+
+  /**
+   * {@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'),
+      $container->get('event_dispatcher')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process(Job $job) {
+    $license_id = $job->getPayload()['license_id'];
+    $license_storage = $this->entityTypeManager->getStorage('commerce_license');
+    /** @var \Drupal\commerce_license\Entity\License $license */
+    $license = $license_storage->load($license_id);
+    if (!$license) {
+      return JobResult::failure('License not found.');
+    }
+
+    try {
+      // Set the license to expired. The plugin will take care of revoking it.
+      $license->state = 'expired';
+      $license->save();
+    }
+    catch (DeclineException $exception) {
+      return $result = JobResult::failure($exception->getMessage());
+    }
+    return JobResult::success();
+  }
+
+}
diff --git a/tests/modules/commerce_license_set_expiry_test/config/install/advancedqueue.advancedqueue_queue.commerce_license.yml b/tests/modules/commerce_license_set_expiry_test/config/install/advancedqueue.advancedqueue_queue.commerce_license.yml
new file mode 100644
index 0000000..9517cf3
--- /dev/null
+++ b/tests/modules/commerce_license_set_expiry_test/config/install/advancedqueue.advancedqueue_queue.commerce_license.yml
@@ -0,0 +1,7 @@
+id: commerce_license
+label: 'Commerce License'
+backend: database
+backend_configuration: {}
+processor: cron
+processing_time: 180
+locked: true
diff --git a/tests/src/Kernel/LicenseCronExpiryTest.php b/tests/src/Kernel/LicenseCronExpiryTest.php
new file mode 100644
index 0000000..08b1bc4
--- /dev/null
+++ b/tests/src/Kernel/LicenseCronExpiryTest.php
@@ -0,0 +1,349 @@
+<?php
+
+namespace Drupal\Tests\commerce_license\Kernel\System;
+
+use Drupal\advancedqueue\Entity\Queue;
+use Drupal\advancedqueue\Job;
+use Drupal\KernelTests\Core\Entity\EntityKernelTestBase;
+use Drupal\Component\Datetime\TimeInterface;
+
+/**
+ * Tests that cron expires a license.
+ *
+ * @group commerce_license
+ */
+class LicenseCronExpiryTest extends EntityKernelTestBase {
+
+  /**
+   * The number of seconds in one day.
+   */
+  const TIME_ONE_DAY = 60 * 60 * 24;
+
+  /**
+   * A timestamp for the license's expiration.
+   *
+   * This is later than the first TODAY, but earlier than TOMORROW.
+   */
+  const TIME_EXPIRES_TODAY = 1234000500 + 100;
+
+  /**
+   * 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 = [
+    'advancedqueue',
+    'system',
+    'user',
+    'state_machine',
+    'commerce',
+    'commerce_price',
+    'commerce_product',
+    'commerce_license',
+    'commerce_license_test',
+    'recurring_period',
+    'commerce_license_set_expiry_test',
+  ];
+
+  /**
+   * 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('user');
+    $this->installConfig('user');
+    $this->installSchema('advancedqueue', 'advancedqueue');
+    $this->installEntitySchema('commerce_license');
+    $this->installConfig('commerce_license_set_expiry_test');
+
+    $this->cron = \Drupal::service('cron');
+    $this->entityTypeManager = \Drupal::service('entity_type.manager');
+  }
+
+  /**
+   * Returns the current time.
+   *
+   * @return int
+   *   The time.
+   */
+  protected static function today() {
+    return time();
+  }
+
+  /**
+   * Returns the time a day ago.
+   *
+   * @return int
+   *   Yesterday's time.
+   */
+  protected static function yesterday() {
+    return self::today() - self::TIME_ONE_DAY;
+  }
+
+  /**
+   * Returns the time a day from now.
+   *
+   * @return int
+   *   Tomorrow's time.
+   */
+  protected static function tomorrow() {
+    return self::today() + self::TIME_ONE_DAY;
+  }
+
+  /**
+   * License hasn't expired.
+   *
+   * Tests that getLicenseIdsToExpire doesn't return a license that hasn't
+   * expired yet.
+   */
+  public function testGetLicenseIdsToExpireTomorrow() {
+    $license_storage = $this->freshLicenseStorage();
+
+    $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.
+    $license->expires = self::tomorrow();
+    $license->save();
+
+    $license_storage = $this->freshLicenseStorage();
+    $expire_ids = $license_storage->getLicenseIdsToExpire(self::today());
+    $this->assertEquals([], $expire_ids, "The license ID is not returned by the expiration query.");
+  }
+
+  /**
+   * License has expired.
+   *
+   * Tests that getLicenseIdsToExpire doesn't return a license that hasn't
+   * expired yet.
+   */
+  public function testGetLicenseIdsToExpireYesterday() {
+    $license_storage = $this->freshLicenseStorage();
+
+    $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.
+    $license->expires = self::yesterday();
+    $license->save();
+
+    $license_storage = $this->freshLicenseStorage();
+    $expire_ids = $license_storage->getLicenseIdsToExpire(self::today());
+    $this->assertEquals([$license->id() => $license->id()], $expire_ids, "The license ID is returned by the expiration query.");
+  }
+
+  /**
+   * Tests that a cron run won't expire a current license.
+   */
+  public function testLicenseCronExpiryCurrent() {
+    $license_storage = $this->freshLicenseStorage();
+
+    $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.
+    $license->expires = self::tomorrow();
+    $license->save();
+
+    $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.');
+  }
+
+  /**
+   * Tests that a cron run expires an expired license.
+   */
+  public function testLicenseCronExpiryExpired() {
+    $license_storage = $this->freshLicenseStorage();
+
+    $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.
+    $license->expires = self::yesterday();
+    $license->save();
+
+    // This cron run sets up the queued jobs.
+    $this->cron->run();
+
+    // Reload the license.
+    $license_storage = $this->freshLicenseStorage();
+    $license = $license_storage->load($license->id());
+
+    /** @var \Drupal\advancedqueue\Entity\QueueInterface $queue */
+    $queue = Queue::load('commerce_license');
+    $counts = array_filter($queue->getBackend()->countJobs());
+    $this->assertEquals([Job::STATE_QUEUED => 1], $counts);
+
+    $job1 = $queue->getBackend()->claimJob();
+    $this->assertArraySubset(['license_id' => $license->id()], $job1->getPayload());
+    $this->assertEquals('commerce_license_expire_expired_license', $job1->getType());
+  }
+
+  /**
+   * Tests that the ExpireExpiredLicenses job expires the license.
+   */
+  public function testExpireExpiredLicensesJob() {
+    $license_storage = $this->freshLicenseStorage();
+
+    $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.
+    $license->expires = self::yesterday();
+    $license->save();
+
+
+    // Reload the license.
+    $license_storage = $this->freshLicenseStorage();
+    $license = $license_storage->load($license->id());
+    $this->assertEquals('active', $license->state->value, "The license is currently active.");
+
+    /** @var \Drupal\advancedqueue\Entity\QueueInterface $queue */
+    $queue = Queue::load('commerce_license');
+    $job = Job::create('commerce_license_expire_expired_license', ['license_id' => $license->id()]);
+    $queue->enqueueJob($job);
+    $processor = $this->container->get('advancedqueue.processor');
+    $num_processed = $processor->processQueue($queue);
+    $this->assertEquals(1, $num_processed);
+
+    $license_storage = $this->freshLicenseStorage();
+    $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.
+  }
+
+  /**
+   * Returns a fresh copy of commerce_license storage.
+   *
+   * 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.)
+   *
+   * @return \Drupal\Core\Entity\EntityStorageInterface
+   *   A fresh copy of commerce_license storage.
+   */
+  protected function freshLicenseStorage() {
+    $this->entityTypeManager->clearCachedDefinitions();
+    $license_storage = $this->entityTypeManager->getStorage('commerce_license');
+    return $license_storage;
+  }
+
+}
