diff --git a/astropy/table/operations.py b/astropy/table/operations.py
index 9ef574d66aae..0850f646012a 100644
--- a/astropy/table/operations.py
+++ b/astropy/table/operations.py
@@ -336,7 +336,8 @@ def join_func(col1, col2):
     return join_func

-def join(left, right, keys=None, join_type='inner',
+def join(left, right, keys=None, join_type='inner', *,
+         keys_left=None, keys_right=None,
          uniq_col_name='{col_name}_{table_name}',
          table_names=['1', '2'], metadata_conflicts='warn',
          join_funcs=None):
@@ -354,6 +355,12 @@ def join(left, right, keys=None, join_type='inner',
         Default is to use all columns which are common to both tables.
     join_type : str
         Join type ('inner' | 'outer' | 'left' | 'right' | 'cartesian'), default is 'inner'
+    keys_left : str or list of str or list of column-like, optional
+        Left column(s) used to match rows instead of ``keys`` arg. This can be
+        be a single left table column name or list of column names, or a list of
+        column-like values with the same lengths as the left table.
+    keys_right : str or list of str or list of column-like, optional
+        Same as ``keys_left``, but for the right side of the join.
     uniq_col_name : str or None
         String generate a unique output column name in case of a conflict.
         The default is '{col_name}_{table_name}'.
@@ -384,7 +391,8 @@ def join(left, right, keys=None, join_type='inner',
     col_name_map = OrderedDict()
     out = _join(left, right, keys, join_type,
                 uniq_col_name, table_names, col_name_map, metadata_conflicts,
-                join_funcs)
+                join_funcs,
+                keys_left=keys_left, keys_right=keys_right)

     # Merge the column and table meta data. Table subclasses might override
     # these methods for custom merge behavior.
@@ -1047,7 +1055,8 @@ def _join(left, right, keys=None, join_type='inner',
           uniq_col_name='{col_name}_{table_name}',
           table_names=['1', '2'],
           col_name_map=None, metadata_conflicts='warn',
-          join_funcs=None):
+          join_funcs=None,
+          keys_left=None, keys_right=None):
     """
     Perform a join of the left and right Tables on specified keys.

@@ -1112,12 +1121,23 @@ def _join(left, right, keys=None, join_type='inner',
         right[cartesian_index_name] = np.uint8(0)
         keys = (cartesian_index_name, )

-    # If we have a single key, put it in a tuple
+    # Handle the case of join key columns that are different between left and
+    # right via keys_left/keys_right args. This is done by saving the original
+    # input tables and making new left and right tables that contain only the
+    # key cols but with common column names ['0', '1', etc]. This sets `keys` to
+    # those fake key names in the left and right tables
+    if keys_left is not None or keys_right is not None:
+        left_orig = left
+        right_orig = right
+        left, right, keys = _join_keys_left_right(
+            left, right, keys, keys_left, keys_right, join_funcs)
+
     if keys is None:
         keys = tuple(name for name in left.colnames if name in right.colnames)
         if len(keys) == 0:
             raise TableMergeError('No keys in common between left and right tables')
     elif isinstance(keys, str):
+        # If we have a single key, put it in a tuple
         keys = (keys,)

     # Check the key columns
@@ -1141,15 +1161,24 @@ def _join(left, right, keys=None, join_type='inner',
     if len_left == 0 or len_right == 0:
         raise ValueError('input tables for join must both have at least one row')

-    # Joined array dtype as a list of descr (name, type_str, shape) tuples
-    col_name_map = get_col_name_map([left, right], keys, uniq_col_name, table_names)
-    out_descrs = get_descrs([left, right], col_name_map)
-
     try:
         idxs, idx_sort = _get_join_sort_idxs(keys, left, right)
     except NotImplementedError:
         raise TypeError('one or more key columns are not sortable')

+    # Now that we have idxs and idx_sort, revert to the original table args to
+    # carry on with making the output joined table. `keys` is set to to an empty
+    # list so that all original left and right columns are included in the
+    # output table.
+    if keys_left is not None or keys_right is not None:
+        keys = []
+        left = left_orig
+        right = right_orig
+
+    # Joined array dtype as a list of descr (name, type_str, shape) tuples
+    col_name_map = get_col_name_map([left, right], keys, uniq_col_name, table_names)
+    out_descrs = get_descrs([left, right], col_name_map)
+
     # Main inner loop in Cython to compute the cartesian product
     # indices for the given join type
     int_join_type = {'inner': 0, 'outer': 1, 'left': 2, 'right': 3,
@@ -1224,6 +1253,58 @@ def _join(left, right, keys=None, join_type='inner',
     return out

+def _join_keys_left_right(left, right, keys, keys_left, keys_right, join_funcs):
+    """Do processing to handle keys_left / keys_right args for join.
+
+    This takes the keys_left/right inputs and turns them into a list of left/right
+    columns corresponding to those inputs (which can be column names or column
+    data values). It also generates the list of fake key column names (strings
+    of "1", "2", etc.) that correspond to the input keys.
+    """
+    def _keys_to_cols(keys, table, label):
+        # Process input `keys`, which is a str or list of str column names in
+        # `table` or a list of column-like objects. The `label` is just for
+        # error reporting.
+        if isinstance(keys, str):
+            keys = [keys]
+        cols = []
+        for key in keys:
+            if isinstance(key, str):
+                try:
+                    cols.append(table[key])
+                except KeyError:
+                    raise ValueError(f'{label} table does not have key column {key!r}')
+            else:
+                if len(key) != len(table):
+                    raise ValueError(f'{label} table has different length from key {key}')
+                cols.append(key)
+        return cols
+
+    if join_funcs is not None:
+        raise ValueError('cannot supply join_funcs arg and keys_left / keys_right')
+
+    if keys_left is None or keys_right is None:
+        raise ValueError('keys_left and keys_right must both be provided')
+
+    if keys is not None:
+        raise ValueError('keys arg must be None if keys_left and keys_right are supplied')
+
+    cols_left = _keys_to_cols(keys_left, left, 'left')
+    cols_right = _keys_to_cols(keys_right, right, 'right')
+
+    if len(cols_left) != len(cols_right):
+        raise ValueError('keys_left and keys_right args must have same length')
+
+    # Make two new temp tables for the join with only the join columns and
+    # key columns in common.
+    keys = [f'{ii}' for ii in range(len(cols_left))]
+
+    left = left.__class__(cols_left, names=keys, copy=False)
+    right = right.__class__(cols_right, names=keys, copy=False)
+
+    return left, right, keys
+
+
 def _check_join_type(join_type, func_name):
     """Check join_type arg in hstack and vstack.

diff --git a/docs/changes/table/11954.api.rst b/docs/changes/table/11954.api.rst
new file mode 100644
index 000000000000..4f43558ec025
--- /dev/null
+++ b/docs/changes/table/11954.api.rst
@@ -0,0 +1,3 @@
+The table ``join`` function now accepts only the first four arguments ``left``,
+``right``, ``keys``, and ``join_type`` as positional arguments. All other
+arguments must be supplied as keyword arguments.
diff --git a/docs/changes/table/11954.feature.rst b/docs/changes/table/11954.feature.rst
new file mode 100644
index 000000000000..cf1d327aba9c
--- /dev/null
+++ b/docs/changes/table/11954.feature.rst
@@ -0,0 +1,5 @@
+Added new keyword arguments ``keys_left`` and ``keys_right`` to the table ``join``
+function to support joining tables on key columns with different names. In
+addition the new keywords can accept a list of column-like objects which are
+used as the match keys. This allows joining on arbitrary data which are not part
+of the tables being joined.
diff --git a/docs/table/operations.rst b/docs/table/operations.rst
index 5e1591a5160b..a2804717037c 100644
--- a/docs/table/operations.rst
+++ b/docs/table/operations.rst
@@ -855,40 +855,31 @@ Non-Identical Key Column Names

 .. EXAMPLE START: Joining Tables with Unique Key Column Names

-The |join| function requires the key column names to be identical in the
-two tables. However, in the following, one table has a ``'name'`` column
-while the other has an ``'obj_id'`` column::
+To use the |join| function with non-identical key column names, use the
+``keys_left`` and ``keys_right`` arguments. In the following example one table
+has a ``'name'`` column while the other has an ``'obj_id'`` column::

   >>> optical = Table.read("""name    obs_date    mag_b  mag_v
   ...                         M31     2012-01-02  17.0   16.0
   ...                         M82     2012-10-29  16.2   15.2
   ...                         M101    2012-10-31  15.1   15.5""", format='ascii')
-  >>> xray_1 = Table.read("""   obj_id    obs_date    logLx
-  ...                           NGC3516 2011-11-11  42.1
-  ...                           M31     1999-01-05  43.1
-  ...                           M82     2012-10-29  45.0""", format='ascii')
-
-In order to perform a match based on the names of the objects, you have to
-temporarily rename one of the columns mentioned above, right before creating
-the new table::
-
-  >>> xray_1.rename_column('obj_id', 'name')
-  >>> opt_xray_1 = join(optical, xray_1, keys='name')
-  >>> xray_1.rename_column('name', 'obj_id')
-  >>> print(opt_xray_1)
-  name obs_date_1 mag_b mag_v obs_date_2 logLx
-  ---- ---------- ----- ----- ---------- -----
-  M31 2012-01-02  17.0  16.0 1999-01-05  43.1
-  M82 2012-10-29  16.2  15.2 2012-10-29  45.0
-
-The original ``xray_1`` table remains unchanged after the operation::
-
-  >>> print(xray_1)
-  obj_id  obs_date  logLx
-  ------- ---------- -----
-  NGC3516 2011-11-11  42.1
-      M31 1999-01-05  43.1
-      M82 2012-10-29  45.0
+  >>> xray_1 = Table.read("""obj_id    obs_date    logLx
+  ...                        NGC3516 2011-11-11  42.1
+  ...                        M31     1999-01-05  43.1
+  ...                        M82     2012-10-29  45.0""", format='ascii')
+
+In order to perform a match based on the names of the objects, do the
+following::
+
+  >>> print(join(optical, xray_1, keys_left='name', keys_right='obj_id'))
+  name obs_date_1 mag_b mag_v obj_id obs_date_2 logLx
+  ---- ---------- ----- ----- ------ ---------- -----
+   M31 2012-01-02  17.0  16.0    M31 1999-01-05  43.1
+   M82 2012-10-29  16.2  15.2    M82 2012-10-29  45.0
+
+The ``keys_left`` and ``keys_right`` arguments can also take a list of column
+names or even a list of column-like objects. The latter case allows specifying
+the matching key column values independent of the tables being joined.

 .. EXAMPLE END

diff --git a/astropy/table/tests/test_operations.py b/astropy/table/tests/test_operations.py
index 2b9488393af2..8b2fcd93bd39 100644
--- a/astropy/table/tests/test_operations.py
+++ b/astropy/table/tests/test_operations.py
@@ -721,6 +721,73 @@ def test_join_with_join_distance_2d(self):
                '     5   -- .. --  0.5 .. 0.0']
         assert str(t12).splitlines() == exp

+    def test_keys_left_right_basic(self):
+        """Test using the keys_left and keys_right args to specify different
+        join keys. This takes the standard test case but renames column 'a'
+        to 'x' and 'y' respectively for tables 1 and 2. Then it compares the
+        normal join on 'a' to the new join on 'x' and 'y'."""
+        self._setup()
+
+        for join_type in ('inner', 'left', 'right', 'outer'):
+            t1 = self.t1.copy()
+            t2 = self.t2.copy()
+            # Expected is same as joining on 'a' but with names 'x', 'y' instead
+            t12_exp = table.join(t1, t2, keys='a', join_type=join_type)
+            t12_exp.add_column(t12_exp['a'], name='x', index=1)
+            t12_exp.add_column(t12_exp['a'], name='y', index=len(t1.colnames) + 1)
+            del t12_exp['a']
+
+            # Different key names
+            t1.rename_column('a', 'x')
+            t2.rename_column('a', 'y')
+            keys_left_list = ['x']  # Test string key name
+            keys_right_list = [['y']]  # Test list of string key names
+            if join_type == 'outer':
+                # Just do this for the outer join (others are the same)
+                keys_left_list.append([t1['x'].tolist()])  # Test list key column
+                keys_right_list.append([t2['y']])  # Test Column key column
+
+            for keys_left, keys_right in zip(keys_left_list, keys_right_list):
+                t12 = table.join(t1, t2, keys_left=keys_left, keys_right=keys_right,
+                                 join_type=join_type)
+
+                assert t12.colnames == t12_exp.colnames
+                for col in t12.values_equal(t12_exp).itercols():
+                    assert np.all(col)
+                assert t12_exp.meta == t12.meta
+
+    def test_keys_left_right_exceptions(self):
+        """Test exceptions using the keys_left and keys_right args to specify
+        different join keys.
+        """
+        self._setup()
+        t1 = self.t1
+        t2 = self.t2
+
+        msg = r"left table does not have key column 'z'"
+        with pytest.raises(ValueError, match=msg):
+            table.join(t1, t2, keys_left='z', keys_right=['a'])
+
+        msg = r"left table has different length from key \[1, 2\]"
+        with pytest.raises(ValueError, match=msg):
+            table.join(t1, t2, keys_left=[[1, 2]], keys_right=['a'])
+
+        msg = r"keys arg must be None if keys_left and keys_right are supplied"
+        with pytest.raises(ValueError, match=msg):
+            table.join(t1, t2, keys_left='z', keys_right=['a'], keys='a')
+
+        msg = r"keys_left and keys_right args must have same length"
+        with pytest.raises(ValueError, match=msg):
+            table.join(t1, t2, keys_left=['a', 'b'], keys_right=['a'])
+
+        msg = r"keys_left and keys_right must both be provided"
+        with pytest.raises(ValueError, match=msg):
+            table.join(t1, t2, keys_left=['a', 'b'])
+
+        msg = r"cannot supply join_funcs arg and keys_left / keys_right"
+        with pytest.raises(ValueError, match=msg):
+            table.join(t1, t2, keys_left=['a'], keys_right=['a'], join_funcs={})
+

 class TestSetdiff():

diff --git a/docs/changes/table/#####.api.rst b/docs/changes/table/#####.api.rst
new file mode 100644
index 0000000000..4f43558ec0
--- /dev/null
+++ b/docs/changes/table/#####.api.rst
@@ -0,0 +1,3 @@
+The table ``join`` function now accepts only the first four arguments ``left``,
+``right``, ``keys``, and ``join_type`` as positional arguments. All other
+arguments must be supplied as keyword arguments.

diff --git a/docs/changes/table/#####.feature.rst b/docs/changes/table/#####.feature.rst
new file mode 100644
index 0000000000..cf1d327aba
--- /dev/null
+++ b/docs/changes/table/#####.feature.rst
@@ -0,0 +1,5 @@
+Added new keyword arguments ``keys_left`` and ``keys_right`` to the table ``join``
+function to support joining tables on key columns with different names. In
+addition the new keywords can accept a list of column-like objects which are
+used as the match keys. This allows joining on arbitrary data which are not part
+of the tables being joined.

diff --git a/docs/table/operations.rst b/docs/table/operations.rst
index 5e1591a516..a280471703 100644
--- a/docs/table/operations.rst
+++ b/docs/table/operations.rst
@@ -855,40 +855,31 @@ Non-Identical Key Column Names

 .. EXAMPLE START: Joining Tables with Unique Key Column Names

-The |join| function requires the key column names to be identical in the
-two tables. However, in the following, one table has a ``'name'`` column
-while the other has an ``'obj_id'`` column::
+To use the |join| function with non-identical key column names, use the
+``keys_left`` and ``keys_right`` arguments. In the following example one table
+has a ``'name'`` column while the other has an ``'obj_id'`` column::

   >>> optical = Table.read("""name    obs_date    mag_b  mag_v
   ...                         M31     2012-01-02  17.0   16.0
   ...                         M82     2012-10-29  16.2   15.2
   ...                         M101    2012-10-31  15.1   15.5""", format='ascii')
-  >>> xray_1 = Table.read("""   obj_id    obs_date    logLx
-  ...                           NGC3516 2011-11-11  42.1
-  ...                           M31     1999-01-05  43.1
-  ...                           M82     2012-10-29  45.0""", format='ascii')
-
-In order to perform a match based on the names of the objects, you have to
-temporarily rename one of the columns mentioned above, right before creating
-the new table::
-
-  >>> xray_1.rename_column('obj_id', 'name')
-  >>> opt_xray_1 = join(optical, xray_1, keys='name')
-  >>> xray_1.rename_column('name', 'obj_id')
-  >>> print(opt_xray_1)
-  name obs_date_1 mag_b mag_v obs_date_2 logLx
-  ---- ---------- ----- ----- ---------- -----
-  M31 2012-01-02  17.0  16.0 1999-01-05  43.1
-  M82 2012-10-29  16.2  15.2 2012-10-29  45.0
-
-The original ``xray_1`` table remains unchanged after the operation::
-
-  >>> print(xray_1)
-  obj_id  obs_date  logLx
-  ------- ---------- -----
-  NGC3516 2011-11-11  42.1
-      M31 1999-01-05  43.1
-      M82 2012-10-29  45.0
+  >>> xray_1 = Table.read("""obj_id    obs_date    logLx
+  ...                        NGC3516 2011-11-11  42.1
+  ...                        M31     1999-01-05  43.1
+  ...                        M82     2012-10-29  45.0""", format='ascii')
+
+In order to perform a match based on the names of the objects, do the
+following::
+
+  >>> print(join(optical, xray_1, keys_left='name', keys_right='obj_id'))
+  name obs_date_1 mag_b mag_v obj_id obs_date_2 logLx
+  ---- ---------- ----- ----- ------ ---------- -----
+   M31 2012-01-02  17.0  16.0    M31 1999-01-05  43.1
+   M82 2012-10-29  16.2  15.2    M82 2012-10-29  45.0
+
+The ``keys_left`` and ``keys_right`` arguments can also take a list of column
+names or even a list of column-like objects. The latter case allows specifying
+the matching key column values independent of the tables being joined.

 .. EXAMPLE END