# Truto-generated key pairs

> Source: https://truto.one/docs/guides/integrations/generated-key-pairs/

Some providers let a service account sign in with a key pair. You install a public key with the provider. The client then proves who it is by signing a short-lived token, a JWT, with the matching private key.

With a Truto-generated key pair, Truto makes that key pair for each connection. Your end user only ever sees the public key, and installs it with the provider. The private key is encrypted, never leaves Truto, and only Truto's JWT signer can read it. Nobody has to create, store or paste a private key.

Snowflake's **Key pair (service user)** method works this way. See the [Snowflake guide](/docs/integration-guides/snowflake) for what your end users see.

## How a connection is made

1. The user picks the method on the connect screen. Truto makes the key pair then, once per connection link, and shows the public key in the method's instructions.
2. The user installs the public key with the provider, for example by running SQL the instructions show, then clicks **Connect**.
3. The connect screen sends back the fingerprint of the key it showed. If that is not the key Truto holds for the link (a waiting key lasts 7 days), Truto refuses, and the screen loads the current key and asks the user to install it.
4. Truto stores the key pair on the connection, and signs a JWT with the private key for the provider's API. It signs a new one before the old one expires.

A reconnect that keeps the method shows the key the connection already has, so nothing needs reinstalling. Only a key that waits for its link asks the user to keep the window open: a screen opened on another link would show another key.

## Configure the credential

A key pair is declared in a credential's `generated_fields`, and used by a `jwt_bearer` credential. This is the shape of Snowflake's:

```json
{
  "jwt_bearer": {
    "format": "jwt_bearer",
    "config": {
      "label": "Key pair (service user)",
      "generated_fields": [
        { "name": "snowflake_key", "type": "rsa_key_pair", "modulus_length": 2048 }
      ],
      "signing": { "id": "", "generated": "snowflake_key" },
      "jwt": {
        "algorithm": "RS256",
        "expires_in": 3300,
        "claims": {},
        "claims_expression": "( $acct := $uppercase($substringBefore($substringAfter(context.base_url, \"://\"), \".\")); $user := $uppercase($trim(context.snowflake_user)); { \"iss\": $acct & \".\" & $user & \".\" & signing.public.fingerprint, \"sub\": $acct & \".\" & $user } )"
      },
      "requires_static_gate": true,
      "fields": [
        { "name": "base_url", "label": "Snowflake Account URL", "type": "text", "required": true },
        { "name": "snowflake_user", "label": "Service user login name", "type": "text", "required": true }
      ],
      "permissions_text": "Add this public key to {{form.snowflake_user}}:\n\n```sql\nALTER USER {{form.snowflake_user}} ADD KEY PAIR TRUTO_{{snowflake_key.key_id_short}} PUBLIC_KEY = '{{snowflake_key.public_key}}';\n```\n\nFingerprint: `{{snowflake_key.fingerprint}}`"
    }
  }
}
```

Snowflake wants `iss` to be `ACCOUNT.USER.SHA256:<fingerprint>` and `sub` to be `ACCOUNT.USER`, with the account and the user in upper case. The expression takes the account from the first part of the account URL's host, `myorg-myaccount` in `https://myorg-myaccount.snowflakecomputing.com`, so an account locator URL loses its region the way Snowflake expects. A claim without the account is refused by Snowflake.

### `generated_fields` entry

| Key | What it is |
| --- | --- |
| `name` | Where the public half is stored on the connection (`context.<name>`), and the start of its placeholders. Letters, digits and underscores, not starting with a digit or with `truto_`. It cannot be a name Truto keeps secrets under (`api_key`, `token`, `oauth`, `secret`, `generated_secrets` and the like), or the name of one of the credential's `fields`. |
| `type` | `rsa_key_pair`. |
| `modulus_length` | Key size in bits: `2048` (the default), `3072` or `4096`. |

A key pair cannot be declared on a credential that connects through a browser redirect, such as a `jwt_bearer` or `oauth2_client_credentials` credential with `auth.authorizeHost`: that flow has no connect screen to show the key on.

An environment override that sets `generated_fields` replaces the integration's list rather than adding to it, like every other array in an override, so it has to repeat every key pair the integration declares. Truto refuses an override that would leave `signing.generated` naming a key pair the credential no longer declares.

A connection's own `integration_override` cannot declare a key pair or set `signing.generated`. On an integration with a key-pair method, it also cannot add or change anything that method could use: anything outside `credentials` (a base URL, a resource, an authorization), or that method's own credential. Those parts can only be removed, all of them or some. Another method's credentials can still be overridden.

### `signing.generated`

The `name` of the key pair the JWT is signed with. The signer reads the private key directly; it is never reached through a `{{…}}` placeholder. When `generated` is set, `signing.key` is ignored.

A JWT signed with a generated key lives at most an hour, so `jwt.expires_in` can be at most `3600`. The key is an RSA key, so `jwt.algorithm` must be `RS256`, `RS384`, `RS512`, `PS256`, `PS384` or `PS512`. Truto refuses a config that breaks either rule, and refuses to sign with one.

### `jwt.claims_expression`

A JSONata expression that returns an object of claims, merged over `jwt.claims`. It runs over:

```json
{
  "context": "the connection's context, without any private key",
  "signing": {
    "id": "signing.id",
    "public": { "fingerprint": "…", "public_key": "…", "key_id": "…" }
  }
}
```

It is never placeholder-substituted, so a value the user typed cannot change the expression. Truto sets `iat` and `exp` itself, after the expression runs, from `jwt.expires_in`. If the expression fails, the error says only that it failed and gives JSONata's error code, so nothing from the context ends up in an error message.

### `requires_static_gate`

When `true`, a connection must have a `truto_static_gate_id`. Use it for a provider whose network policy admits only Truto's Static Gate IP addresses: a connection without the gate could never make a call. Truto refuses:

- a connect without one, and the connect screen says so before it shows a key, so nobody installs a key that cannot connect;
- `POST /integrated-account` without one in the `context`;
- a `PATCH` that would take it away from a connection that has one.

A link gets its gate from `truto_static_gate_id` when the link token is created, or from **Advanced settings → Static gate** in the dashboard's **Get connection link** dialog. A re-authorize link starts with the connection's own gate.

## Write the instructions

`permissions_text` is markdown shown on the connect screen once the user picks the method. For a key-pair method it can use these placeholders:

| Placeholder | Filled in with |
| --- | --- |
| `{{<name>.public_key}}` | The public key as one line of base64 (DER SubjectPublicKeyInfo), with no PEM header. |
| `{{<name>.public_key_pem}}` | The public key in PEM form. |
| `{{<name>.fingerprint}}` | `SHA256:` followed by the base64 SHA-256 of the DER public key. |
| `{{<name>.key_id}}` | The key's random ID. |
| `{{<name>.key_id_short}}` | The first 8 characters of `key_id`, in upper case. Use it to name the key with the provider, so that installing the key of another connection does not clash with an old one. |
| `{{form.<field>}}` | What the user has typed into that field so far. |

Truto fills in the key's placeholders before the screen gets the text. The connect screen fills in `{{form.<field>}}` as the user types:

- Only the plain form is supported: no fallbacks, defaults or type casts.
- A field with nothing in it keeps its placeholder, so a half-filled form shows what is still missing.
- The value is shown as Connect will save it: trimmed, with the field's own `transform` and the credential's `transform_expression` applied.
- A `password` field is never filled in.

Every code block in the instructions gets a **Copy** button. Write steps the user runs as code blocks, and make them safe to run twice: users close the window, reopen the link and run the steps again. In a numbered list, indent a code block under its step, or the numbering starts again at 1 after it.

## Replacing a key

Truto does not rotate a connection's key yet. A reconnect that keeps the method keeps the key the connection already has. To move to a new key, connect a new account with a new connection link, then delete the old one. The new account has a new ID, and the old account's synced data goes with it when it is deleted.

## Accounts created through the API

`POST /integrated-account` with a key-pair credential makes the key pair when it creates the account. The response carries the public half under `context.<name>`. Nothing is signed with the key until something needs a token, so the account is created even though the provider does not have the key yet. For the same reason, the integration's post-install and validation actions wait. Install the key with the provider, then call `POST /integrated-account/run-post-install-actions` with the account's `id`: it runs those actions, as a connect does, and sends `integrated_account:active` once they pass. A request made before the key is installed fails.

If the method has `requires_static_gate`, put `truto_static_gate_id` in the account's `context`.

## Why the private key cannot be read

- It is encrypted with the connection's other secrets, and no API returns it: not `GET /integrated-account/{id}`, not `GET /integrated-account/me`, and no webhook.
- It is removed from the context that every template, mapping and expression gets: `{{…}}` placeholders in an override's headers, query, body or base URL, unified API mappings, sync jobs, workflows, actions and unified webhooks. So no configuration, and no debug response, can pull it into a request or a response.
- Only the JWT signer reads it, through `signing.generated`.
- While a connection link waits for **Connect**, its key pair is kept encrypted, and it is deleted once the connection holds it.
- The connect screen only ever receives the public half.
