diff --git a/aws/cognito/clients.tf b/aws/cognito/clients.tf
index ff710d133..2a5324433 100644
--- a/aws/cognito/clients.tf
+++ b/aws/cognito/clients.tf
@@ -80,7 +80,15 @@ resource "aws_cognito_user_pool_client" "nextstrain-cli" {
 
   name = "nextstrain-cli"
 
-  # Allow Secure Remote Password (SRP) auth, plus refresh token auth (required).
+  # Allow client to use OAuth (with the authorization code grant type only)
+  # against the user pool, plus Secure Remote Password (SRP) auth and refresh
+  # token auth (required).
+  allowed_oauth_flows_user_pool_client = true
+  allowed_oauth_flows                  = ["code"]
+  allowed_oauth_scopes                 = ["email", "openid", "phone", "profile"]
+
+  supported_identity_providers = ["COGNITO"]
+
   explicit_auth_flows = [
     "ALLOW_USER_SRP_AUTH",
     "ALLOW_REFRESH_TOKEN_AUTH",
@@ -96,6 +104,39 @@ resource "aws_cognito_user_pool_client" "nextstrain-cli" {
     refresh_token = "days"
   }
 
+  # Allowed redirection destinations to complete authentication.
+  #
+  # We'd prefer to use 127.0.0.1 instead of localhost to avoid name resolution
+  # issues on end user systems, issues which are known to occur.  The "OAuth
+  # 2.0 for native apps" best current practice (RFC 8252) suggests as much¹, but
+  # alas, Cognito's https-requirement exception for localhost is not applied to
+  # 127.0.0.1.
+  #
+  # Similarly, we'd prefer to register without an explicit port and rely on the
+  # same RFC's stipulation of relaxed port matching for localhost², but alack,
+  # Cognito doesn't follow that either and requires strict port matching.
+  #
+  # Since the CLI may not always be able to listen on a specific port given
+  # other services that might be running, and there's also value in random
+  # choice making interception harder, register a slew of ports for use and let
+  # Nextstrain CLI draw from the list.
+  #  -trs, 19 Nov 2023
+  #
+  # ¹ <https://datatracker.ietf.org/doc/html/rfc8252#section-8.3>
+  # ² <https://datatracker.ietf.org/doc/html/rfc8252#section-7.3>
+  callback_urls = formatlist("http://localhost:%d/", random_integer.nextstrain_cli_callback_port[*].result)
+
   read_attributes  = local.user_attributes
   write_attributes = setsubtract(local.user_attributes, ["email_verified", "phone_number_verified"])
 }
+
+resource "random_integer" "nextstrain_cli_callback_port" {
+  # AWS Cognito supports 100 callback URLs per client
+  # <https://docs.aws.amazon.com/cognito/latest/developerguide/limits.html#resource-quotas>
+  count = 99
+
+  # IANA-defined port range for dynamic use.
+  # <https://datatracker.ietf.org/doc/html/rfc6335#section-6>
+  min = 49152
+  max = 65535
+}
diff --git a/aws/cognito/outputs.tf b/aws/cognito/outputs.tf
index e8cdd8cfe..edc3878f0 100644
--- a/aws/cognito/outputs.tf
+++ b/aws/cognito/outputs.tf
@@ -14,6 +14,10 @@ output "OAUTH2_CLI_CLIENT_ID" {
   value = aws_cognito_user_pool_client.nextstrain-cli.id
 }
 
+output "OAUTH2_CLI_CLIENT_REDIRECT_URIS" {
+  value = aws_cognito_user_pool_client.nextstrain-cli.callback_urls
+}
+
 output "OAUTH2_LOGOUT_URL" {
   value = format("https://%s/logout", coalesce(
     one(aws_cognito_user_pool_domain.custom[*].domain),
diff --git a/aws/iam/policy/NextstrainDotOrgServerInstance-testing.tftpl.json b/aws/iam/policy/NextstrainDotOrgServerInstance-testing.tftpl.json
index b6f210543..c7efe204e 100644
--- a/aws/iam/policy/NextstrainDotOrgServerInstance-testing.tftpl.json
+++ b/aws/iam/policy/NextstrainDotOrgServerInstance-testing.tftpl.json
@@ -55,6 +55,16 @@
       "Resource": [
         "arn:aws:cognito-idp:us-east-1:827581582529:userpool/${COGNITO_USER_POOL_ID}"
       ]
+    },
+    {
+      "Sid": "GetResourcesIndex",
+      "Effect": "Allow",
+      "Action": [
+        "s3:GetObject"
+      ],
+      "Resource": [
+        "arn:aws:s3:::nextstrain-inventories/resources.json.gz"
+      ]
     }
   ]
 }
diff --git a/aws/iam/policy/NextstrainDotOrgServerInstance.tftpl.json b/aws/iam/policy/NextstrainDotOrgServerInstance.tftpl.json
index 3d423d6b5..a3594d510 100644
--- a/aws/iam/policy/NextstrainDotOrgServerInstance.tftpl.json
+++ b/aws/iam/policy/NextstrainDotOrgServerInstance.tftpl.json
@@ -44,6 +44,16 @@
       "Resource": [
         "arn:aws:cognito-idp:us-east-1:827581582529:userpool/${COGNITO_USER_POOL_ID}"
       ]
+    },
+    {
+      "Sid": "GetResourcesIndex",
+      "Effect": "Allow",
+      "Action": [
+        "s3:GetObject"
+      ],
+      "Resource": [
+        "arn:aws:s3:::nextstrain-inventories/resources.json.gz"
+      ]
     }
   ]
 }
diff --git a/docs/production.rst b/docs/production.rst
index b8f6e8452..c26e6fd35 100644
--- a/docs/production.rst
+++ b/docs/production.rst
@@ -239,6 +239,7 @@ file are::
     OAUTH2_CLIENT_ID
     OAUTH2_CLIENT_SECRET
     OAUTH2_CLI_CLIENT_ID
+    OAUTH2_CLI_CLIENT_REDIRECT_URIS
     OIDC_USERNAME_CLAIM
     OIDC_GROUPS_CLAIM
 
@@ -255,6 +256,9 @@ Clients
 Two OAuth 2.0 clients (sometimes called "applications") must be registered with
 the IdP.
 
+App server client
+~~~~~~~~~~~~~~~~~
+
 A `confidential, web application client <oauth2-clients_>`__ is required for
 use by the app server to implement browser-based sessions.  Its id and secret
 are configured by `OAUTH2_CLIENT_ID` and `OAUTH2_CLIENT_SECRET`.  The app
@@ -270,24 +274,52 @@ server does not strictly require a secret.  The client registration must allow:
 
   - a logout redirection URL of `https://<host>`
 
-Token lifetimes for this client should be configured with consideration that
-the id token lifetime affects how often background renewal requests are
-necessary and the refresh token lifetime limits the maximum duration of web
-sessions.
+.. _oauth2-clients: https://datatracker.ietf.org/doc/html/rfc6749#section-2.1
+.. _PKCE: https://datatracker.ietf.org/doc/html/rfc7636
+
+CLI client
+~~~~~~~~~~
 
 A `public, native application client <oauth2-clients_>`__ is required for use
 by the :doc:`Nextstrain CLI <cli:index>` and is permitted by the app server to
 make `Bearer`-authenticated requests.  Its id is configured by
-`OAUTH2_CLI_CLIENT_ID`.
+`OAUTH2_CLI_CLIENT_ID`.  The client registration must allow:
 
-.. note::
-    Currently Nextstrain CLI is tightly bound to AWS Cognito and requires
-    its Secure Remote Password authentication flow implemented outside of
-    the standard OAuth 2.0 flows.  We anticipate changing this in the
-    future.
+  - the authorization code flow, ideally with PKCE_ support
 
-.. _oauth2-clients: https://datatracker.ietf.org/doc/html/rfc6749#section-2.1
-.. _PKCE: https://datatracker.ietf.org/doc/html/rfc7636
+  - issuance of refresh tokens, either by default or by requesting the
+    `offline_access` scope
+
+  - at least one authentication redirection (sometimes "callback") URL of
+    `http://127.0.0.1:<port>/` or `http://localhost:<port>/`
+
+The CLI auto-discovers its OpenID client configuration (and the IdP
+configuration) from the app server.  The app server must be configured to know
+the CLI client's redirect URIs with `OAUTH2_CLI_CLIENT_REDIRECT_URIS` so the
+URLs can be included in the discovery response.
+
+If the IdP allows for `http://` redirect URIs for loopback IPs (e.g.
+`127.0.0.1`), then the loopback IP should be preferred over using `localhost`,
+as per best current practice described in `RFC 8252 § 8.3`_.
+
+If the IdP allows relaxed port matching for loopback IP/localhost redirect
+URIs, as per best current practice described in `RFC 8252 § 7.3`_, then only a
+single redirect URI needs to be registered with the IdP.  Otherwise, multiple
+redirect URIs with varying ports should be registered to allow the CLI
+alternatives to choose from in case it can't bind a given port on a user's
+computer.
+
+.. _RFC 8252 § 7.3: https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
+.. _RFC 8252 § 8.3: https://datatracker.ietf.org/doc/html/rfc8252#section-8.3
+
+
+Token lifetimes
+~~~~~~~~~~~~~~~
+
+Token lifetimes for the clients should be configured with consideration that
+the id token lifetime affects how often background renewal requests are
+necessary and the refresh token lifetime limits the maximum duration of web or
+CLI sessions.
 
 
 Authorization role groups
diff --git a/env/outputs.tf b/env/outputs.tf
index f52d611d5..595107bf6 100644
--- a/env/outputs.tf
+++ b/env/outputs.tf
@@ -23,6 +23,10 @@ output "OAUTH2_CLI_CLIENT_ID" {
   value = module.cognito.OAUTH2_CLI_CLIENT_ID
 }
 
+output "OAUTH2_CLI_CLIENT_REDIRECT_URIS" {
+  value = module.cognito.OAUTH2_CLI_CLIENT_REDIRECT_URIS
+}
+
 output "OAUTH2_LOGOUT_URL" {
   value = module.cognito.OAUTH2_LOGOUT_URL
 }
diff --git a/env/production/.terraform.lock.hcl b/env/production/.terraform.lock.hcl
index 4681d02a9..c114af0a0 100644
--- a/env/production/.terraform.lock.hcl
+++ b/env/production/.terraform.lock.hcl
@@ -20,3 +20,22 @@ provider "registry.terraform.io/hashicorp/aws" {
     "zh:f4b86e7df4e846a38774e8e648b41c5ebaddcefa913cfa1864568086b7735575",
   ]
 }
+
+provider "registry.terraform.io/hashicorp/random" {
+  version = "3.5.1"
+  hashes = [
+    "h1:VSnd9ZIPyfKHOObuQCaKfnjIHRtR7qTw19Rz8tJxm+k=",
+    "zh:04e3fbd610cb52c1017d282531364b9c53ef72b6bc533acb2a90671957324a64",
+    "zh:119197103301ebaf7efb91df8f0b6e0dd31e6ff943d231af35ee1831c599188d",
+    "zh:4d2b219d09abf3b1bb4df93d399ed156cadd61f44ad3baf5cf2954df2fba0831",
+    "zh:6130bdde527587bbe2dcaa7150363e96dbc5250ea20154176d82bc69df5d4ce3",
+    "zh:6cc326cd4000f724d3086ee05587e7710f032f94fc9af35e96a386a1c6f2214f",
+    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+    "zh:b6d88e1d28cf2dfa24e9fdcc3efc77adcdc1c3c3b5c7ce503a423efbdd6de57b",
+    "zh:ba74c592622ecbcef9dc2a4d81ed321c4e44cddf7da799faa324da9bf52a22b2",
+    "zh:c7c5cde98fe4ef1143bd1b3ec5dc04baf0d4cc3ca2c5c7d40d17c0e9b2076865",
+    "zh:dac4bad52c940cd0dfc27893507c1e92393846b024c5a9db159a93c534a3da03",
+    "zh:de8febe2a2acd9ac454b844a4106ed295ae9520ef54dc8ed2faf29f12716b602",
+    "zh:eab0d0495e7e711cca367f7d4df6e322e6c562fc52151ec931176115b83ed014",
+  ]
+}
diff --git a/env/production/config.json b/env/production/config.json
index d27393b06..c5e6a168d 100644
--- a/env/production/config.json
+++ b/env/production/config.json
@@ -3,6 +3,107 @@
   "OIDC_IDP_URL": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_Cg5rcTged",
   "OAUTH2_CLIENT_ID": "rki99ml8g2jb9sm1qcq9oi5n",
   "OAUTH2_CLI_CLIENT_ID": "2vmc93kj4fiul8uv40uqge93m5",
+  "OAUTH2_CLI_CLIENT_REDIRECT_URIS": [
+    "http://localhost:49154/",
+    "http://localhost:49208/",
+    "http://localhost:49233/",
+    "http://localhost:49278/",
+    "http://localhost:49852/",
+    "http://localhost:50049/",
+    "http://localhost:50146/",
+    "http://localhost:50208/",
+    "http://localhost:50290/",
+    "http://localhost:50552/",
+    "http://localhost:50555/",
+    "http://localhost:50560/",
+    "http://localhost:50978/",
+    "http://localhost:51122/",
+    "http://localhost:51182/",
+    "http://localhost:51357/",
+    "http://localhost:51494/",
+    "http://localhost:51716/",
+    "http://localhost:51838/",
+    "http://localhost:51841/",
+    "http://localhost:51861/",
+    "http://localhost:51924/",
+    "http://localhost:52109/",
+    "http://localhost:52176/",
+    "http://localhost:52191/",
+    "http://localhost:52258/",
+    "http://localhost:52560/",
+    "http://localhost:52629/",
+    "http://localhost:53113/",
+    "http://localhost:53369/",
+    "http://localhost:53995/",
+    "http://localhost:54137/",
+    "http://localhost:54211/",
+    "http://localhost:54378/",
+    "http://localhost:54568/",
+    "http://localhost:54971/",
+    "http://localhost:55027/",
+    "http://localhost:55341/",
+    "http://localhost:55396/",
+    "http://localhost:55535/",
+    "http://localhost:55536/",
+    "http://localhost:55555/",
+    "http://localhost:55610/",
+    "http://localhost:55825/",
+    "http://localhost:56014/",
+    "http://localhost:56361/",
+    "http://localhost:56691/",
+    "http://localhost:56846/",
+    "http://localhost:56978/",
+    "http://localhost:57264/",
+    "http://localhost:57282/",
+    "http://localhost:57578/",
+    "http://localhost:57856/",
+    "http://localhost:57875/",
+    "http://localhost:58039/",
+    "http://localhost:58199/",
+    "http://localhost:58638/",
+    "http://localhost:59095/",
+    "http://localhost:59462/",
+    "http://localhost:59507/",
+    "http://localhost:59628/",
+    "http://localhost:59804/",
+    "http://localhost:59906/",
+    "http://localhost:59942/",
+    "http://localhost:60139/",
+    "http://localhost:60257/",
+    "http://localhost:60377/",
+    "http://localhost:60564/",
+    "http://localhost:60579/",
+    "http://localhost:60705/",
+    "http://localhost:60775/",
+    "http://localhost:61015/",
+    "http://localhost:61309/",
+    "http://localhost:61376/",
+    "http://localhost:61384/",
+    "http://localhost:61399/",
+    "http://localhost:61588/",
+    "http://localhost:61915/",
+    "http://localhost:62350/",
+    "http://localhost:62478/",
+    "http://localhost:62752/",
+    "http://localhost:62947/",
+    "http://localhost:63087/",
+    "http://localhost:63124/",
+    "http://localhost:63230/",
+    "http://localhost:63257/",
+    "http://localhost:63514/",
+    "http://localhost:63519/",
+    "http://localhost:63638/",
+    "http://localhost:63692/",
+    "http://localhost:63838/",
+    "http://localhost:64029/",
+    "http://localhost:64098/",
+    "http://localhost:64294/",
+    "http://localhost:64873/",
+    "http://localhost:65081/",
+    "http://localhost:65266/",
+    "http://localhost:65271/",
+    "http://localhost:65311/"
+  ],
   "OAUTH2_LOGOUT_URL": "https://login.nextstrain.org/logout",
   "COGNITO_USER_POOL_ID": "us-east-1_Cg5rcTged",
   "OIDC_USERNAME_CLAIM": "cognito:username",
diff --git a/env/testing/.terraform.lock.hcl b/env/testing/.terraform.lock.hcl
index 4681d02a9..c114af0a0 100644
--- a/env/testing/.terraform.lock.hcl
+++ b/env/testing/.terraform.lock.hcl
@@ -20,3 +20,22 @@ provider "registry.terraform.io/hashicorp/aws" {
     "zh:f4b86e7df4e846a38774e8e648b41c5ebaddcefa913cfa1864568086b7735575",
   ]
 }
+
+provider "registry.terraform.io/hashicorp/random" {
+  version = "3.5.1"
+  hashes = [
+    "h1:VSnd9ZIPyfKHOObuQCaKfnjIHRtR7qTw19Rz8tJxm+k=",
+    "zh:04e3fbd610cb52c1017d282531364b9c53ef72b6bc533acb2a90671957324a64",
+    "zh:119197103301ebaf7efb91df8f0b6e0dd31e6ff943d231af35ee1831c599188d",
+    "zh:4d2b219d09abf3b1bb4df93d399ed156cadd61f44ad3baf5cf2954df2fba0831",
+    "zh:6130bdde527587bbe2dcaa7150363e96dbc5250ea20154176d82bc69df5d4ce3",
+    "zh:6cc326cd4000f724d3086ee05587e7710f032f94fc9af35e96a386a1c6f2214f",
+    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+    "zh:b6d88e1d28cf2dfa24e9fdcc3efc77adcdc1c3c3b5c7ce503a423efbdd6de57b",
+    "zh:ba74c592622ecbcef9dc2a4d81ed321c4e44cddf7da799faa324da9bf52a22b2",
+    "zh:c7c5cde98fe4ef1143bd1b3ec5dc04baf0d4cc3ca2c5c7d40d17c0e9b2076865",
+    "zh:dac4bad52c940cd0dfc27893507c1e92393846b024c5a9db159a93c534a3da03",
+    "zh:de8febe2a2acd9ac454b844a4106ed295ae9520ef54dc8ed2faf29f12716b602",
+    "zh:eab0d0495e7e711cca367f7d4df6e322e6c562fc52151ec931176115b83ed014",
+  ]
+}
diff --git a/env/testing/config.json b/env/testing/config.json
index 29e05b6f6..68af5aafe 100644
--- a/env/testing/config.json
+++ b/env/testing/config.json
@@ -3,6 +3,106 @@
   "OIDC_IDP_URL": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_zqpCrjM7I",
   "OAUTH2_CLIENT_ID": "6qiojrhr8tibt0f6hphnm1osp1",
   "OAUTH2_CLI_CLIENT_ID": "9opa27o74f4jsq8g4a34e1mqr",
+  "OAUTH2_CLI_CLIENT_REDIRECT_URIS": [
+    "http://localhost:49161/",
+    "http://localhost:49334/",
+    "http://localhost:49359/",
+    "http://localhost:49398/",
+    "http://localhost:49603/",
+    "http://localhost:50044/",
+    "http://localhost:50110/",
+    "http://localhost:50132/",
+    "http://localhost:50467/",
+    "http://localhost:50667/",
+    "http://localhost:50712/",
+    "http://localhost:51264/",
+    "http://localhost:51333/",
+    "http://localhost:51413/",
+    "http://localhost:51467/",
+    "http://localhost:51596/",
+    "http://localhost:51664/",
+    "http://localhost:51953/",
+    "http://localhost:51974/",
+    "http://localhost:51977/",
+    "http://localhost:52272/",
+    "http://localhost:52342/",
+    "http://localhost:52361/",
+    "http://localhost:52564/",
+    "http://localhost:52621/",
+    "http://localhost:52673/",
+    "http://localhost:53216/",
+    "http://localhost:53267/",
+    "http://localhost:53375/",
+    "http://localhost:53624/",
+    "http://localhost:53644/",
+    "http://localhost:54071/",
+    "http://localhost:55078/",
+    "http://localhost:55286/",
+    "http://localhost:55296/",
+    "http://localhost:55357/",
+    "http://localhost:55419/",
+    "http://localhost:55462/",
+    "http://localhost:55724/",
+    "http://localhost:56972/",
+    "http://localhost:57135/",
+    "http://localhost:57194/",
+    "http://localhost:57255/",
+    "http://localhost:57321/",
+    "http://localhost:57411/",
+    "http://localhost:57564/",
+    "http://localhost:57591/",
+    "http://localhost:57649/",
+    "http://localhost:57654/",
+    "http://localhost:57883/",
+    "http://localhost:58259/",
+    "http://localhost:58549/",
+    "http://localhost:58826/",
+    "http://localhost:59071/",
+    "http://localhost:59359/",
+    "http://localhost:59688/",
+    "http://localhost:60055/",
+    "http://localhost:60107/",
+    "http://localhost:60205/",
+    "http://localhost:60590/",
+    "http://localhost:60790/",
+    "http://localhost:60883/",
+    "http://localhost:60897/",
+    "http://localhost:60911/",
+    "http://localhost:61155/",
+    "http://localhost:61325/",
+    "http://localhost:61369/",
+    "http://localhost:61400/",
+    "http://localhost:61406/",
+    "http://localhost:61553/",
+    "http://localhost:62190/",
+    "http://localhost:62405/",
+    "http://localhost:62439/",
+    "http://localhost:62467/",
+    "http://localhost:62638/",
+    "http://localhost:62726/",
+    "http://localhost:63016/",
+    "http://localhost:63103/",
+    "http://localhost:63309/",
+    "http://localhost:63318/",
+    "http://localhost:63387/",
+    "http://localhost:63480/",
+    "http://localhost:63526/",
+    "http://localhost:63704/",
+    "http://localhost:63743/",
+    "http://localhost:63775/",
+    "http://localhost:64008/",
+    "http://localhost:64373/",
+    "http://localhost:64410/",
+    "http://localhost:64446/",
+    "http://localhost:64520/",
+    "http://localhost:64694/",
+    "http://localhost:64960/",
+    "http://localhost:64972/",
+    "http://localhost:65032/",
+    "http://localhost:65086/",
+    "http://localhost:65113/",
+    "http://localhost:65121/"
+  ],
   "OAUTH2_LOGOUT_URL": "https://nextstrain-testing.auth.us-east-1.amazoncognito.com/logout",
   "COGNITO_USER_POOL_ID": "us-east-1_zqpCrjM7I",
   "OIDC_USERNAME_CLAIM": "cognito:username",
diff --git a/src/app.js b/src/app.js
index d1d010950..a37b01d19 100644
--- a/src/app.js
+++ b/src/app.js
@@ -454,6 +454,15 @@ app.route("/schemas/*")
   .all((req, res, next) => next(new NotFound()));
 
 
+/* OpenID Connect 1.0 configuration.  Retrieved by Nextstrain CLI to
+ * discovery necessary authentication details.
+ *
+ * <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>
+ */
+app.routeAsync("/.well-known/openid-configuration")
+  .getAsync(endpoints.openid.providerConfiguration);
+
+
 /* Auspice HTML pages and assets.
  *
  * Auspice hardcodes URL paths that start with /dist/… in its Webpack config,
diff --git a/src/config.js b/src/config.js
index f64ce8bcf..7fe400583 100644
--- a/src/config.js
+++ b/src/config.js
@@ -246,6 +246,24 @@ export const OAUTH2_LOGOUT_URL = fromEnvOrConfig("OAUTH2_LOGOUT_URL", OIDC_CONFI
 export const OAUTH2_SCOPES_SUPPORTED = new Set(fromEnvOrConfig("OAUTH2_SCOPES_SUPPORTED", OIDC_CONFIGURATION.scopes_supported));
 
 
+/**
+ * Effective OpenID Connect (OIDC) identity provider configuration document
+ * after potential local overrides.
+ *
+ * Defined here to keep the overridden fields close to their declarations
+ * above.
+ */
+export const EFFECTIVE_OIDC_CONFIGURATION = {
+  ...OIDC_CONFIGURATION,
+  issuer: OIDC_ISSUER_URL,
+  jwks_uri: OIDC_JWKS_URL,
+  authorization_endpoint: OAUTH2_AUTHORIZATION_URL,
+  token_endpoint: OAUTH2_TOKEN_URL,
+  end_session_endpoint: OAUTH2_LOGOUT_URL,
+  scopes_supported: OAUTH2_SCOPES_SUPPORTED,
+};
+
+
 /**
  * OAuth2 client id of nextstrain.org server as registered with our IdP (e.g.
  * our Cognito user pool).
@@ -279,6 +297,23 @@ export const OAUTH2_CLIENT_SECRET = fromEnvOrConfig("OAUTH2_CLIENT_SECRET", null
 export const OAUTH2_CLI_CLIENT_ID = fromEnvOrConfig("OAUTH2_CLI_CLIENT_ID");
 
 
+/**
+ * OAuth2 client redirect URIs (e.g. callback URLs) for Nextstrain CLI as
+ * registered with the IdP.
+ *
+ * These URLs are not themselves used by the server but are provided to
+ * (discovered by) Nextstrain CLI in a client configuration section of the
+ * OpenID configuration document served at /.well-known/openid-configuration.
+ *
+ * The name of this config var uses "redirect_uri" as the term since that's the
+ * literal field name used by the OIDC/OAuth2 specs in several places (initial
+ * auth requests, client metadata registration/querying, etc.).
+ *
+ * @type {string[]}
+ */
+export const OAUTH2_CLI_CLIENT_REDIRECT_URIS = fromEnvOrConfig("OAUTH2_CLI_CLIENT_REDIRECT_URIS");
+
+
 /**
  * ID token claim field containing the username for a user.
  *
diff --git a/src/endpoints/index.js b/src/endpoints/index.js
index 6a9a57b4e..31d1870a7 100644
--- a/src/endpoints/index.js
+++ b/src/endpoints/index.js
@@ -1,6 +1,7 @@
 import * as charon from './charon/index.js';
 import * as cli from './cli.js';
 import * as groups from "./groups.js";
+import * as openid from './openid.js';
 import * as options from './options.js';
 import * as sources from './sources.js';
 import * as static_ from './static.js';
@@ -10,6 +11,7 @@ export {
   charon,
   cli,
   groups,
+  openid,
   options,
   sources,
   static_ as static,
diff --git a/src/endpoints/openid.js b/src/endpoints/openid.js
new file mode 100644
index 000000000..e1e778205
--- /dev/null
+++ b/src/endpoints/openid.js
@@ -0,0 +1,68 @@
+/**
+ * OpenID Connect 1.0 endpoints.
+ *
+ * @module endpoints.openid
+ */
+
+import {
+  EFFECTIVE_OIDC_CONFIGURATION,
+  OAUTH2_CLI_CLIENT_ID,
+  OAUTH2_CLI_CLIENT_REDIRECT_URIS,
+  OIDC_USERNAME_CLAIM,
+  OIDC_GROUPS_CLAIM,
+  COGNITO_USER_POOL_ID,
+} from "../config.js";
+
+
+/**
+ * A client configuration document for Nextstrain CLI to automatically discover
+ * the client metadata it should use for itself.
+ *
+ * Based on the OIDC dynamic client registration spec, see
+ * {@link https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata} and
+ * {@link https://openid.net/specs/openid-connect-registration-1_0.html#ReadResponse}.
+ */
+const cliClientConfiguration = {
+  client_id: OAUTH2_CLI_CLIENT_ID,
+
+  // Static/assumed values asserted by the CLI, but informative to include.
+  application_type: "native",
+  response_types: ["code"],
+  grant_types: ["authorization_code"],
+
+  /* Used to know the list of ports that can be listened to on localhost, as
+   * not all IdPs follow RFC 8252 § 7.3.¹
+   *
+   * ¹ <https://datatracker.ietf.org/doc/html/rfc8252#section-7.3.>
+   */
+  redirect_uris: OAUTH2_CLI_CLIENT_REDIRECT_URIS,
+
+  /********************************************
+   * Custom metadata fields below this point. *
+   ********************************************/
+
+  /* Used by the CLI for display purposes only, but nice to get the display
+   * right.
+   */
+  id_token_username_claim: OIDC_USERNAME_CLAIM,
+  id_token_groups_claim: OIDC_GROUPS_CLAIM,
+
+  /* Used for Secure Remote Password auth flow with Cognito outside the
+   * OIDC/OAuth2 protocols.
+   */
+  aws_cognito_user_pool_id: COGNITO_USER_POOL_ID,
+};
+
+
+/**
+ * IdP metadata for the /.well-known/openid-configuration endpoint.
+ *
+ * As the spec allows, we extend the metadata with a client configuration
+ * section for Nextstrain CLI to allow it to perform automatic discovery.
+ *
+ * Refer to {@link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata}.
+ */
+export const providerConfiguration = (req, res) => res.json({
+  ...EFFECTIVE_OIDC_CONFIGURATION,
+  nextstrain_cli_client_configuration: cliClientConfiguration,
+});