Authentication

OAuth 2.0 authorization

This page applies to:

  • HAProxy 2.5 and newer
  • HAProxy Enterprise 2.5r1 and newer
  • HAProxy ALOHA 14.0 and newer

With the OAuth 2.0 authorization protocol, you can protect your applications from unauthorized access by requiring that clients send a JSON Web Token with their HTTP requests, proving that they have the necessary set of permissions.

For example, let’s suppose you’ve developed an online service named My Recipes where users can save cooking recipes. Separately, a team of developers creates a client app named Ingredients that helps find low prices on cooking ingredients. You want to allow the ingredients app to access your recipes service to make it easy for users to find recipes that match their ingredients shopping list. But you want to ensure that users consent to the ingredients app accessing their recipes first.

The OAuth 2.0 protocol focuses on allowing third-party applications to access user data or resources without exposing credentials. It isn’t meant for single sign-on or for the user to sign in to an app. It’s meant for granting access between apps and services on behalf of a user. There are several ways to get a user’s consent, including prompting the user. However, for our purposes, we’ll stick with the most straight-forward workflow, which is to give the asking application (the ingredients app) a unique client ID and a secret phrase. We’ll associate the client ID with the permissions we want to grant, so that that part will be set upfront, rather than prompting the user to choose the level of access they want to give. This is called the client credentials flow. It’s ideal for scenarios where you don’t need to involve the user in choosing which permissions to grant and it’s permissible to set permissions beforehand.

It works like this:

  1. As the developer of the My Recipes service, you register the client application (Ingredients app) with your OAuth 2.0 authorization server and assign to it a new client ID and secret. You’ll associate that client with the permissions you want to give it.

  2. The Ingredients app developers configure their client application to store their client ID and secret within their app.

  3. Prior to making a request to the target service (recipes service), the client app will use its client ID and secret to make a request to the OAuth 2.0 authorization server.

  4. The OAuth 2.0 authorization server will respond with a JSON Web Token that contains the client’s permissions.

  5. The client application will make a request with the JSON Web Token to the target service.

  6. HAProxy will validate the token and, if it’s valid and contains the necessary permissions, will allow the request to proceed to recipe server in the backend.

Configure an authorization server Jump to heading

A JSON Web Token conveys a client application’s level of access. Its payload is base64-encoded JSON data that’s cryptographically signed by the OAuth 2.0 authorization server during authentication. In this section, we cover how to register a client application with an OAuth 2.0 authorization server, get a client ID and secret according to the client credentials flow, and set permissions for the client app.

Auth0 Jump to heading

You can sign up for an Auth0 account to authorize client applications before they can access your API. The scenario we’ll describe here is an app using OAuth 2.0 to access an API.

To register your API service and client app with Auth0:

  1. Create an account with Auth0 and then log in to your account.

  2. Go to Applications > APIs and click Create API. In Auth0, an API is the entity that receives the tokens and grants access, which in our case is HAProxy. But you can create multiple APIs with different names, depending on the number of backend applications you have, and route them all through HAProxy. Think of it as the public face of your application, with HAProxy serving as a proxy.

    When creating the API, set:

    • Name to a name for your API, such as My Recipes API.
    • Identifier to the HTTPS address you’d like to use for accessing your application. You’ll need to configure your DNS server to send this traffic to HAProxy. For example, use https://recipes.mywebsite.com. This address will get included in the token as the aud claim, which we’ll check in our HAProxy configuration to make sure that the token we receive is meant for this API.
    • JSON Web Token (JWT) Signing Algorithm to either RS256 or HS256. The RS256 option uses an X.509 certificate to verify the signature, while HS256 uses a shared secret.
  3. Click Create to create the API.

  4. Optional: After creating the API, you can edit it and then create permissions to control the access given to client applications. By default, all client applications get unrestricted access to the API if they present a valid token. With permissions, however, you can define more specific levels of access and assign them to clients selectively. For example, you could create permissions named read:recipes and write:recipes.

  5. To register the client app you want to give access to, go to Applications > Applications to click Create Application.

    When creating the application, set:

    • Name to a name for the client app, such as Ingredients App.
    • Application Type to Machine to Machine Application.
    • Select the API you created, My Recipes API, as the authorized API.
    • Optional: If you’ve defined permissions on your API, you can select them here to grant them to the client app.
  6. Click Authorize to give access to the client application.

  7. Go to Applications > Applications > [Your App] > Settings > Advanced Settings > Certificates and download the certificate in the PEM format. Extract the public key from the downloaded PEM file using the openssl x509 command:

    nix
    openssl x509 -pubkey -noout -in ./myaccount.pem > pubkey.pem
    nix
    openssl x509 -pubkey -noout -in ./myaccount.pem > pubkey.pem

    Store the public key file on your load balancer server.

    Use jwt_verify_cert in newer versions

    Beginning in HAProxy 3.3, you no longer need to extract the public key from the certificate. By using the jwt_verify_cert converter in your configuration instead of the older jwt_verify converter, the key will be extracted automatically.

  8. Go to Applications > Applications > [Your App] > Credentials to copy the client ID and client secret, which you can then copy to the client app’s settings.

  9. To see it working, go to Applications > Applications > [Your App] > Quick Start for an example curl call that gets a token. For example:

    nix
    curl --request POST \
    --url https://myaccount.auth0.com/oauth/token \
    --header 'content-type: application/json' \
    --data '{
    "client_id":"abcd12345...",
    "client_secret":"ABCD12345...",
    "audience":"https://recipes.mywebsite.com",
    "grant_type":"client_credentials"
    }'
    nix
    curl --request POST \
    --url https://myaccount.auth0.com/oauth/token \
    --header 'content-type: application/json' \
    --data '{
    "client_id":"abcd12345...",
    "client_secret":"ABCD12345...",
    "audience":"https://recipes.mywebsite.com",
    "grant_type":"client_credentials"
    }'
    output
    json
    {
    "access_token":"eyJhbGciOiJSUzI1NiCUfGiCxRykRKvWnP4Nr...",
    "scope":"read:recipes",
    "expires_in":86400,
    "token_type":"Bearer"
    }
    output
    json
    {
    "access_token":"eyJhbGciOiJSUzI1NiCUfGiCxRykRKvWnP4Nr...",
    "scope":"read:recipes",
    "expires_in":86400,
    "token_type":"Bearer"
    }

    This response contains a JSON Web Token as access_token, any assigned permissions as scope, the time in seconds until expiration, and the token type. The client application should attach the token to its HTTP requests via an Authentication header.

    What's inside a JSON Web Token?

    To see what’s inside the token, use the debugger at the JSON Web Token Debugger website, which can display the JWT fields inside the access_token value.

    For example:

    json
    {
    "alg": "RS256",
    "typ": "JWT"
    }
    {
    "iss": "https://myaccount.auth0.com/",
    "aud": "https://api.mywebsite.com",
    "exp": 1662753594,
    "scope": "read:myapp write:myapp",
    "gty": "client-credentials"
    }
    {
    // RSASHA256 signature
    }
    json
    {
    "alg": "RS256",
    "typ": "JWT"
    }
    {
    "iss": "https://myaccount.auth0.com/",
    "aud": "https://api.mywebsite.com",
    "exp": 1662753594,
    "scope": "read:myapp write:myapp",
    "gty": "client-credentials"
    }
    {
    // RSASHA256 signature
    }

    A JSON Web Token contains three parts:

    • a header
    • a payload
    • a cryptographic signature

    The header indicates which algorithm was used to sign the token. The payload contains the name of the issuer, the intended audience, the expiration date, and any permissions (also known as scopes). The signature proves the authenticity and integrity of the token.

Keycloak Jump to heading

You can use Keycloak to authorize client applications before they can access your API. The scenario we’ll describe here is an app using OAuth 2.0 to access an API.

To register your API service and client app with Keycloak:

  1. Log in to the Keycloak Admin Console, which listens on port 8080.

  2. Select a realm or create a new realm. For example, create a realm named recipes-api. A realm contains the settings for your services and the clients you want to authorize.

    Realm name

    Don’t create a realm with a name that has spaces in it. This can cause errors later on.

  3. Go to Configure > Realm settings and select the Tokens tab.

    Set Default Signature Algorithm to one of the values that HAProxy supports: ES256, ES384, ES512, HS256, HS384, HS512, PS256, PS384, PS512 RS256, RS384, or RS512. The algorithms that begin with ES, RS, and PS use an X.509 certificate to verify the signature, while HS algorithms use a shared secret.

  4. Optional: You can create permissions to control the access given to client applications. By default, all client applications get unrestricted access to the API if they present a valid token. With permissions, however, you can define more specific levels of access and assign them to clients selectively. For example, you could create permissions named read:recipes and write:recipes.

    • Go to Manage > Client scopes and click Create client scope.
    • Set Name to the name of the permission, such as read:recipes.
    • Set Include in token scope to on, which adds this permission to the scope claim in tokens.

    Click Save.

  5. To register the client app you want to give access to, go to Manage > Clients and click Create client.

    When creating the client, set:

    • Client type to OpenID Connect, which is a protocol built upon OAuth.
    • Client ID to a unique identifier for the client application. For example, use ingredients-app or use a GUID.
    • Name to a display-friendly name, such as Ingredients App.
    • Description to a short description, such as An application for finding deals on ingredients.

    Click Next.

  6. On the next screen, which is Capability config, set:

    • Client authentication to on. This enables confidential access.
    • Service account roles as an enabled authentication flow. This enables the Client credentials flow.

    Click Next.

  7. On the next screen, which is Login settings, click Save.

  8. Optional: To assign a permission to the client:

    • Go to Manage > Clients and choose the client from the list (e.g. ingredients-app).
    • Select the Client scopes tab.
    • Click Add client scope.
    • Select the permission (e.g. read:recipes).
    • Click Add > Default.
  9. To set the aud claim in access tokens, which we’ll check in our HAProxy configuration to make sure that the tokens we receive are meant for this API:

    • Go to Manage > Clients and choose the client from the list (e.g. ingredients-app).
    • Select the Client scopes tab.
    • Select the <client-id>-dedicated scope (e.g. ingredients-app-dedicated).
    • The Mappers screen displays. Click Configure a new mapper and select Audience.
    • The Add Mapper screen displays.
    • Set Name to audience-mapper.
    • Set Included Custom Audience to the HTTPS address you’d like to use for accessing your API. You’ll need to configure your DNS server to send this traffic to HAProxy. For example, use https://recipes.mywebsite.com.

    Click Save.

  10. Download the public key that you can use to verify the tokens that Keycloak signs.

    • Go to Configure > Realm settings and select the Keys tab.
    • Download the public key depending on the signature algorithm you configured. For example, save the public key for the RS256 algorithm as pubkey.pem.
    • Store the public key file on your load balancer server.
  11. Go to Manage > Clients and choose the client from the list (e.g. ingredients-app).

    • Select the Credentials tab.
    • Copy the client secret, which you can then copy to the client app’s settings, along with the client ID.
  12. To see it working, try using curl to make a request to Keycloak for a new token. Send the client ID and client secret.

    nix
    curl --request POST \
    --url 'http://localhost:8080/realms/recipes-api/protocol/openid-connect/token' \
    --data 'client_id=ingredients-app' \
    --data 'client_secret=t1eAKDBTz4DR9CRsjmHcJBqAXVNSwHBt' \
    --data 'grant_type=client_credentials'
    nix
    curl --request POST \
    --url 'http://localhost:8080/realms/recipes-api/protocol/openid-connect/token' \
    --data 'client_id=ingredients-app' \
    --data 'client_secret=t1eAKDBTz4DR9CRsjmHcJBqAXVNSwHBt' \
    --data 'grant_type=client_credentials'
    output
    json
    {
    "access_token":"eyJhbGciOiJSUzI1NiCUfGiCxRykRKvWnP4Nr...",
    "expires_in":300,
    "refresh_expires_in":0,
    "token_type":"Bearer",
    "not-before-policy":0,
    "scope":"read:recipes profile email"
    }
    output
    json
    {
    "access_token":"eyJhbGciOiJSUzI1NiCUfGiCxRykRKvWnP4Nr...",
    "expires_in":300,
    "refresh_expires_in":0,
    "token_type":"Bearer",
    "not-before-policy":0,
    "scope":"read:recipes profile email"
    }

    This response contains a JSON Web Token as access_token, any assigned permissions as scope, the time in seconds until expiration, and the token type. The client application should attach the token to its HTTP requests via an Authentication header.

Microsoft Entra ID Jump to heading

You can use Microsoft Entra ID to authorize client applications before they can access your API. The scenario we’ll describe here is an app using OAuth 2.0 to access an API. Microsoft Entra ID uses RS256 as the token signing algorithm and you cannot change this.

To register your API service and client app with Microsoft Entra ID:

  1. Sign in to the Microsoft Azure portal.

  2. Search for and select Microsoft Entra ID. If you manage multiple tenants, choose Manage tenants, select a tenant, and then click Switch.

  3. Register the API that you want to protect with OAuth. From the Microsoft Entra ID Overview page, go to Manage > App registrations in the left-hand menu and choose New registration. On the Register an application screen, set:

    • a name for the application for which you’re enabling OAuth-based authorization, such as My Recipes API.
    • supported account types to Single tenant only.

    Click Register.

  4. To expose this API for OAuth, go to Manage > App registrations and select your API (e.g. My Recipes API).

    • Click Expose an API.
    • Next to Application ID URI, click Add. The Application ID URI has a value like api://e42b4c0c-f14b-476a-9995-96adb78fbb92. This will be the value of the aud claim in the access token. Save this value to use later.
    • Click Save.
  5. Register the client application that will access the API. Go to Manage > App registrations in the left-hand menu and choose New registration. On the Register an application screen, set:

    • a name for the client application, such as Ingredients App.
    • supported account types to Single tenant only.

    Click Register.

  6. From the overview screen, add a client secret to the client application:

    • Copy the Application (client) ID to use later. It has a value like e202df99-99ef-48db-a41d-b42c5f1b8bf1.
    • Click Add a certificate or secret.
    • Add a client secret and give it a description that indicates the client application it belongs to (e.g. Ingredients App).
    • Click Add.
    • Copy the client secret value to use later. It has a value like 1z-8Q~iarOwhIFkugwuu4D5inK.2bF1V007bBbDf.
  7. From the app registrations overview screen, select Endpoints and copy the endpoint URL named OAuth 2.0 token endpoint (v2). It has a value like https://login.microsoftonline.com/<TENANT-ID>/oauth2/v2.0/token. This is the endpoint from which client applications will request access tokens.

  8. Optional: You can create permissions to control the access given to client applications. By default, all client applications get unrestricted access to the API if they present a valid token. With permissions, however, you can define more specific levels of access and assign them to clients selectively. For example, you could create permissions named read:recipes and write:recipes.

    • Go to Manage > App registrations and select your API (e.g. My Recipes API).
    • Go to App roles and click Create app role.
    • Set the app role’s display name, such as Read recipes.
    • Set Allowed member types to Both.
    • Set the value, such as read:recipes.
    • Set the description, such as Allow reading recipes.
    • Click Apply.
  9. Optional: Assign a permission to the client application.

    • Go to Manage > App registrations and select the client application (e.g. Ingredients App).
    • Go to API permissions and click Add a permission.
    • Under APIs my organization uses, select your API (e.g. My Recipes API).
    • Select the permission to assign, such as Read recipes.
    • Click Add permission.
    • To immediately grant this permission without needing consent during the authorization workflow, click Grant admin consent.

    Permissions are in the roles claim

    Microsoft Entra ID puts permissions into the access token as a claim named roles, unlike other OAuth 2.0 providers that put them into a claim named scope.

  10. To see it working, try using curl to make a request to Microsoft Entra ID for a new token. Send the client ID and client secret.

    nix
    curl --request POST \
    --url 'https://login.microsoftonline.com/<YOUR-TENANT-ID>/oauth2/v2.0/token' \
    --data 'scope=<APPLICATION-ID-URI>/.default' \
    --data 'client_id=<CLIENT-ID>' \
    --data 'client_secret=<CLIENT-SECRET>' \
    --data 'grant_type=client_credentials'
    nix
    curl --request POST \
    --url 'https://login.microsoftonline.com/<YOUR-TENANT-ID>/oauth2/v2.0/token' \
    --data 'scope=<APPLICATION-ID-URI>/.default' \
    --data 'client_id=<CLIENT-ID>' \
    --data 'client_secret=<CLIENT-SECRET>' \
    --data 'grant_type=client_credentials'

    For example:

    nix
    curl --request POST \
    --url 'https://login.microsoftonline.com/abcd1234-defg-1234-abcd-efgi53456789/oauth2/v2.0/token' \
    --data 'scope=api://e42b4c0c-f14b-476a-9995-96adb78fbb92/.default' \
    --data 'client_id=e202df99-99ef-48db-a41d-b42c5f1b8bf1' \
    --data 'client_secret=1z-8Q~iarOwhIFkugwuu4D5inK.2bF1V007bBbDf' \
    --data 'grant_type=client_credentials'
    nix
    curl --request POST \
    --url 'https://login.microsoftonline.com/abcd1234-defg-1234-abcd-efgi53456789/oauth2/v2.0/token' \
    --data 'scope=api://e42b4c0c-f14b-476a-9995-96adb78fbb92/.default' \
    --data 'client_id=e202df99-99ef-48db-a41d-b42c5f1b8bf1' \
    --data 'client_secret=1z-8Q~iarOwhIFkugwuu4D5inK.2bF1V007bBbDf' \
    --data 'grant_type=client_credentials'
    output
    json
    {
    "token_type":"Bearer",
    "expires_in":3599,
    "ext_expires_in":3599,
    "access_token":"yJhbGciOiJSUzI1NiCUfGiCxRykRKvWnP4Nr..."
    }
    output
    json
    {
    "token_type":"Bearer",
    "expires_in":3599,
    "ext_expires_in":3599,
    "access_token":"yJhbGciOiJSUzI1NiCUfGiCxRykRKvWnP4Nr..."
    }

    This response contains a JSON Web Token as access_token, the time in seconds until expiration, and the token type. The client application should attach the token to its HTTP requests via an Authentication header.

Configure HAProxy Jump to heading

HAProxy provides configuration directives that cover all of the functionality needed to validate JSON Web Tokens, including checking that a token:

  • hasn’t expired.
  • was cryptographically signed by a trusted authorization server.
  • is meant for your application and not someone else’s.
  • contains any necessary permissions (typically defined in the token as scope), such as read and write permissions.

HAProxy proxies traffic to your application, handling the validation of JSON Web Tokens and denying unauthorized requests. For example, if the client sends an HTTP PUT request, indicating that they want to update data, but they don’t have the write permission, you can deny the request. Beyond validation and granting access, you can also relay the token’s claims to your backend application via HTTP headers for additional processing.

Validate tokens Jump to heading

To configure HAProxy for token validation, make these changes to your frontend configuration section:

  1. Add a bind directive that enables HTTPS via the ssl and crt arguments. This ensures that tokens will be sent over a secure connection. For details on how to configure HTTPS, see Basics - Enable TLS.

    haproxy
    frontend myfrontend
    bind :80
    bind :443 ssl crt /etc/hapee-3.3/certs/mywebsite.com/ssl.pem
    ...
    haproxy
    frontend myfrontend
    bind :80
    bind :443 ssl crt /etc/hapee-3.3/certs/mywebsite.com/ssl.pem
    ...
  2. Add directives that validate tokens.

    haproxy
    # Deny requests missing an Authorization header
    http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }
    # extract header part of the token
    http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')
    # extract payload part of the token
    http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')
    http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')
    http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')
    http-request set-var(txn.scope) http_auth_bearer,jwt_payload_query('$.scope')
    # Validate the token
    http-request deny content-type 'text/html' string 'Unsupported token signing algorithm' unless { var(txn.alg) -m str RS256 }
    http-request deny content-type 'text/html' string 'Invalid token issuer' unless { var(txn.iss) -m str https://myaccount.auth0.com/ }
    http-request deny content-type 'text/html' string 'Invalid token audience' unless { var(txn.aud) -m str https://recipes.mywebsite.com }
    http-request deny content-type 'text/html' string 'Invalid token signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/path/to/pubkey.pem") -m int 1 }
    http-request set-var(txn.now) date()
    http-request deny content-type 'text/html' string 'Token has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }
    haproxy
    # Deny requests missing an Authorization header
    http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }
    # extract header part of the token
    http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')
    # extract payload part of the token
    http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')
    http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')
    http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')
    http-request set-var(txn.scope) http_auth_bearer,jwt_payload_query('$.scope')
    # Validate the token
    http-request deny content-type 'text/html' string 'Unsupported token signing algorithm' unless { var(txn.alg) -m str RS256 }
    http-request deny content-type 'text/html' string 'Invalid token issuer' unless { var(txn.iss) -m str https://myaccount.auth0.com/ }
    http-request deny content-type 'text/html' string 'Invalid token audience' unless { var(txn.aud) -m str https://recipes.mywebsite.com }
    http-request deny content-type 'text/html' string 'Invalid token signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/path/to/pubkey.pem") -m int 1 }
    http-request set-var(txn.now) date()
    http-request deny content-type 'text/html' string 'Token has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 }

    In this example:

    • We deny requests that are missing an Authorization HTTP header.
    • We extract the token from the Authorization HTTP header by using the http_auth_bearer fetch method.
    • The jwt_header_query converter extracts fields from the token’s header. The header contains the alg field, which indicates the algorithm used to sign the token, either RS256 or HS256.
    • The jwt_payload_query converter extracts fields from the payload. The payload contains the iss field (issuer, which is the authorization server’s URL), aud (audience, your application’s URL), exp (token’s expiration date), and scope (optional permissions). For Microsoft Entra ID, change change scope to roles. Different OAuth 2.0 services may use different names for the fields in the token. Inspect the token or consult the service’s documentation to determine the proper field names.
    • We store the header and payload fields in variables by using the http-request set-var directive.
    • We validate that the JWT’s fields and signature are valid for the expected issuer, audience, and signing algorithm. The http-request deny directives deny requests that fail validation in some way and return an error message to the client application. Change these values for your setup.
    • To verify the token’s signature, we use the jwt_verify converter. Because we’ve set the expected alg to RS256, we pass in the public key that we got from the authorization server. When using HS256, pass in the shared secret instead of the public key. Change these values for your setup. Some authorization servers, such as Microsoft Entra ID, let you get the public key from a discovery URL endpoint (e.g. https://login.microsoftonline.com/<YOUR-TENANT-ID>/discovery/keys). Match up the kid (key ID) from the decrypted access token’s header with the keys listed in the discovery endpoint’s response to find the right key.
  3. Optional: Deny requests based on specific permissions with http-request deny directives.

    haproxy
    http-request deny if { method GET } ! { var(txn.scope) -m sub read }
    http-request deny if { method DELETE POST PUT } ! { var(txn.scope) -m sub write }
    haproxy
    http-request deny if { method GET } ! { var(txn.scope) -m sub read }
    http-request deny if { method DELETE POST PUT } ! { var(txn.scope) -m sub write }

    In this example:

    • Deny GET requests if missing the read scope.
    • Deny DELETE, POST, and PUT requests if missing the write scope.
    • For Microsoft Entra ID, use roles instead of scope.
  4. Test it by calling your load balanced API at its listening address. In your request, set the token in the Authorization header.

    nix
    curl --request GET \
    --insecure \
    --url https://recipes.mywebsite.com \
    --header 'authorization: Bearer eyJhbGciOiJSUzI1NiCUfGiCxRykRKvWnP4Nr...'
    nix
    curl --request GET \
    --insecure \
    --url https://recipes.mywebsite.com \
    --header 'authorization: Bearer eyJhbGciOiJSUzI1NiCUfGiCxRykRKvWnP4Nr...'

See also Jump to heading

For complete information on the directives related to OAuth authorization, see the HAProxy Configuration Manual:

Do you have any suggestions on how we can improve the content of this page?