Skip to content

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 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:

{
  "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:

{
  "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.