Setting up AWS Glue Zero-ETL integration with Salesforce
Setting up AWS Glue Zero-ETL integration with Salesforce
Server-to-server integrations shouldn’t need a human to click “Allow” in a browser. The OAuth 2.0 JWT Bearer Flow lets a backend process prove its identity to Salesforce with a signed token, with no interactive login and no password stored in your pipeline.
This guide has two parts:
- Part 1: Set up Salesforce for JWT bearer authentication and verify it with a local Python test.
- Part 2: Use that setup to build an AWS Glue zero-ETL integration that replicates Salesforce data into AWS.
The big picture
flowchart TD
subgraph P1["Part 1: Salesforce JWT setup"]
S1["Step 1: Generate RSA key and certificate"] --> S2["Step 2: Create External Client App"]
S2 --> S3["Step 3: OAuth scopes and pre-authorization"]
S3 --> S4["Step 4: Sign a JWT"]
S4 --> S5["Step 5: Exchange JWT for access token"]
S5 --> S6["Step 6: Verify HTTP 200"]
end
subgraph P2["Part 2: AWS Glue zero-ETL"]
S7["Step 7: Create IAM role"] --> S8["Step 8: Store JWT in Secrets Manager"]
S8 --> S9["Step 9: Create Glue connection"]
S9 --> S10["Step 10: Prepare the target"]
S10 --> S11["Step 11: Create zero-ETL integration"]
S11 --> S12["Step 12: Verify data"]
end
S6 --> S7
Part 1: Salesforce JWT Bearer Flow
How it works
You create an RSA key pair. Salesforce holds the public certificate, and your integration holds the private key. To authenticate, your code signs a short-lived JWT with the private key and sends it to Salesforce’s token endpoint. Salesforce verifies the signature against the certificate and returns an access token.
sequenceDiagram
participant App as Integration (holds server.key)
participant SF as Salesforce token endpoint
App->>App: Sign JWT with private key (RS256)
App->>SF: POST /services/oauth2/token (grant_type=jwt-bearer, assertion=JWT)
SF->>SF: Verify signature against uploaded certificate
SF->>SF: Check iss, sub, aud, exp and jti
SF-->>App: 200 OK (access_token, instance_url)
App->>SF: API calls using the access token
Step 1: Generate an RSA private key and certificate
Install OpenSSL on your Windows machine (for example via the Git for Windows bundle, or a standalone build), then run:
openssl genrsa -out server.key 2048
openssl req -new -x509 -key server.key -out server.crt -days 730 -subj "/CN=salesforce-jwt-integration"
server.keyis the private key. Keep it secret and never commit it to source control.server.crtis the X.509 public certificate you’ll upload to Salesforce.- The certificate validity (
-days 730) is a rotation deadline, so set a reminder before it expires.
Step 2: Configure the Salesforce External Client App
In Salesforce Setup, create (or open) your External Client App and go to OAuth Settings:
- Enable OAuth.
- Enable the JWT Bearer Flow.
- Upload
server.crtas the certificate. - Disable OAuth Device Flow. This integration is server-to-server, so the device flow isn’t needed and leaving it off reduces your attack surface.
Copy the app’s Consumer Key (Client ID). You’ll need it as the JWT issuer.
Step 3: Configure OAuth scopes and app authorization
- Add the scope Perform requests at any time (refresh_token, offline_access).
- Under OAuth Policies, set Permitted Users to Admin approved users are pre-authorized, then assign the integration user’s profile or a permission set to the app.
Pre-authorization is what makes the flow non-interactive. If the user isn’t pre-approved, Salesforce rejects the JWT with an invalid_grant error saying the user hasn’t approved the consumer.
Step 4: Generate and sign the JWT
Install the dependencies:
pip install pyjwt cryptography requests
The JWT has these claims:
| Claim | Value |
|---|---|
iss |
The External Client App’s Consumer Key (Client ID) |
sub |
The Salesforce integration username |
aud |
https://login.salesforce.com (use https://test.salesforce.com for sandboxes, or your My Domain URL) |
iat |
Current time |
exp |
Current time plus a short window (3 minutes or less) |
jti |
A unique ID for each token |
It’s signed with RSA SHA-256 (RS256) using your private key. Two notes:
exponly limits how long this one assertion can be exchanged for a token. It has nothing to do with how long your integration keeps working.- A unique
jtiper token protects against replay. Reusing the same signed JWT, including through automatic retries, can fail withjti already in use.
Step 5: Exchange the JWT for an access token
The script below signs a fresh JWT, posts it to the token endpoint, and checks the result without ever printing the access token.
import os
import time
import uuid
import jwt
import requests
CLIENT_ID = os.environ["SF_CLIENT_ID"] # Consumer Key
SF_USERNAME = os.environ["SF_USERNAME"] # Integration user
LOGIN_URL = "https://login.salesforce.com" # test.salesforce.com for sandbox
with open("server.key", "r") as f:
private_key = f.read()
now = int(time.time())
claims = {
"iss": CLIENT_ID,
"sub": SF_USERNAME,
"aud": LOGIN_URL,
"iat": now,
"exp": now + 180, # short-lived assertion
"jti": str(uuid.uuid4()), # unique per token
}
assertion = jwt.encode(claims, private_key, algorithm="RS256")
response = requests.post(
f"{LOGIN_URL}/services/oauth2/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
},
timeout=30,
)
print("HTTP status:", response.status_code)
if response.ok:
body = response.json()
print("Authentication: Successful")
print("Salesforce instance URL:", body.get("instance_url"))
print("Access token received:", bool(body.get("access_token")))
else:
print("Authentication: Failed")
print("Error:", response.json())
On Windows, set the environment variables before running:
$env:SF_CLIENT_ID = "<consumer key>"
$env:SF_USERNAME = "<integration username>"
python test_jwt.py
Step 6: Verify the result
A successful run prints:
HTTP status: 200
Authentication: Successful
Salesforce instance URL: <your Salesforce instance URL>
Access token received: True
That confirms Salesforce accepted the JWT, validated the signature against your certificate, and issued an access token.
Note: This test consumes the JWT’s
jti. Never reuse the same signed JWT in Glue. Sign a separate one for it.
Part 2: AWS Glue zero-ETL integration
Once the JWT test returns a 200, you can wire the same External Client App into Glue. The flow is: create an IAM role, store the JWT in Secrets Manager, create the Glue connection, set up the target, then create the integration.
Architecture
flowchart LR
SF["Salesforce (External Client App)"]
SM["AWS Secrets Manager (salesforce-etl/jwt-credentials)"]
ROLE["IAM role (salesforce-glue-zero-etl-role)"]
CONN["Glue connection (Salesforce, JWT bearer)"]
ZETL["Glue zero-ETL integration"]
CAT["Glue Data Catalog (salesforce database)"]
S3["Amazon S3 (target bucket)"]
ATH["Amazon Athena (query)"]
SM -->|JWT read by Glue| CONN
ROLE -->|assumed by Glue| CONN
CONN -->|OAuth token exchange| SF
SF -->|object data| ZETL
CONN --> ZETL
ZETL --> CAT
ZETL --> S3
CAT --> ATH
S3 --> ATH
Using this with AWS Glue: ground rules
- Sign a new JWT every time you create, update, or test the connection. Pasting a previously used token causes
jti already in use. - Don’t test the same JWT elsewhere first. The Python test above consumes that token’s
jti, so generate a separate one for Glue. - Plan for long-running connections. The JWT is exchanged for an access token at connection time, and the access token expires per your connected app’s session settings. See Operating it long-term.
Step 7: Create the IAM role
Create a role named salesforce-glue-zero-etl-role that Glue can assume.
Trust policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "glue.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
Permissions policy
Replace <s3_bucket_name>, <region>, and <account_id> with your values.
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:ListAllMyBuckets",
"s3:GetBucketAcl",
"ec2:DescribeVpcEndpoints",
"ec2:DescribeRouteTables",
"ec2:CreateNetworkInterface",
"ec2:DeleteNetworkInterface",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeVpcAttribute",
"iam:ListRolePolicies",
"iam:GetRole",
"iam:GetRolePolicy",
"cloudwatch:PutMetricData"
],
"Effect": "Allow",
"Resource": ["*"]
},
{
"Action": ["s3:Get*", "s3:PutObject"],
"Effect": "Allow",
"Resource": [
"arn:aws:s3:::<s3_bucket_name>",
"arn:aws:s3:::<s3_bucket_name>/*"
]
},
{
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:AssociateKmsKey"
],
"Effect": "Allow",
"Resource": ["*"]
},
{
"Action": ["ec2:CreateTags", "ec2:DeleteTags"],
"Condition": {
"ForAllValues:StringEquals": {
"aws:TagKeys": ["aws-glue-service-resource"]
}
},
"Effect": "Allow",
"Resource": [
"arn:aws:ec2:*:*:network-interface/*",
"arn:aws:ec2:*:*:security-group/*",
"arn:aws:ec2:*:*:instance/*"
]
},
{
"Sid": "SecretsManager",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret",
"secretsmanager:PutSecretValue"
],
"Effect": "Allow",
"Resource": [
"arn:aws:secretsmanager:<region>:<account_id>:secret:salesforce-etl/credentials-*",
"arn:aws:secretsmanager:<region>:<account_id>:secret:salesforce-etl/jwt-credentials-*"
]
},
{
"Action": [
"glue:CreateTable",
"glue:GetTable",
"glue:GetTables",
"glue:DeleteTable",
"glue:UpdateTable",
"glue:GetTableVersion",
"glue:GetTableVersions",
"glue:GetResourcePolicy"
],
"Resource": [
"arn:aws:glue:<region>:<account_id>:catalog",
"arn:aws:glue:<region>:<account_id>:database/salesforce",
"arn:aws:glue:<region>:<account_id>:table/salesforce/*"
],
"Effect": "Allow"
},
{
"Action": ["glue:GetDatabase"],
"Resource": [
"arn:aws:glue:<region>:<account_id>:catalog",
"arn:aws:glue:<region>:<account_id>:database/salesforce"
],
"Effect": "Allow"
},
{
"Action": ["cloudwatch:PutMetricData"],
"Resource": "*",
"Condition": {
"StringEquals": {
"cloudwatch:namespace": "AWS/Glue/ZeroETL"
}
},
"Effect": "Allow"
},
{
"Sid": "GlueConnections",
"Effect": "Allow",
"Action": ["glue:GetConnections", "glue:GetConnection"],
"Resource": [
"arn:aws:glue:*:<account_id>:catalog",
"arn:aws:glue:<region>:<account_id>:connection/*"
]
},
{
"Sid": "GlueActionBasedPermissions",
"Effect": "Allow",
"Action": ["glue:ListEntities", "glue:RefreshOAuth2Tokens"],
"Resource": "*"
}
]
}
What each part does
| Area | What it allows | Scoped to |
|---|---|---|
| S3 | Read and write the integration’s staging and target data | arn:aws:s3:::<s3_bucket_name> and /* |
| Secrets Manager | GetSecretValue, DescribeSecret, PutSecretValue so Glue can read credentials and write back refreshed tokens |
salesforce-etl/credentials-* and salesforce-etl/jwt-credentials-* |
| Glue Data Catalog | Create, read, update, and delete tables, and read the database | The salesforce database and its tables |
| Glue connections | GetConnection, GetConnections |
Connections in your account and region |
| Glue OAuth | ListEntities, RefreshOAuth2Tokens |
* |
| CloudWatch | PutMetricData, limited to the AWS/Glue/ZeroETL namespace |
* |
| CloudWatch Logs | Create log groups and streams, write events | * |
| EC2 networking | Describe VPC resources and manage Glue’s network interfaces (tag changes limited to the aws-glue-service-resource key) |
* and EC2 resource ARNs |
| IAM (read-only) | GetRole, GetRolePolicy, ListRolePolicies |
* |
Hardening ideas
- Scope the CloudWatch Logs statement to
/aws-glue/*log groups instead of*. - The unconditioned
cloudwatch:PutMetricDatain the first statement makes the namespace-restricted statement redundant, so remove it from the first statement. - If the secret or S3 bucket uses a customer-managed KMS key, add
kms:Decryptandkms:GenerateDataKeyfor that key:<kms_key_arn>.
Step 8: Store the credentials in Secrets Manager
Create a secret named salesforce-etl/jwt-credentials in ap-southeast-1 (or your region). The role’s policy matches secret names that start with salesforce-etl/jwt-credentials, so keep the secret name and the policy ARN in sync.
The secret holds the signed JWT under the key TOKEN:
{
"TOKEN": "<freshly signed JWT>"
}
Reference it only by name or ARN:
arn:aws:secretsmanager:<region>:<account_id>:secret:salesforce-etl/jwt-credentials-<suffix>
Because each JWT is single-use, the safe routine is: sign a new JWT, update the secret, and then create, update, or test the connection straight away.
flowchart LR
A["Sign new JWT (new jti, exp 3 min or less)"] --> B["Update secret TOKEN value"]
B --> C["Create, update or test Glue connection"]
C -->|jti already in use| A
C -->|success| D["Connection ready"]
To update the secret from the CLI without leaving the JWT in your shell history, write it to a temporary file first:
aws secretsmanager put-secret-value \
--region ap-southeast-1 \
--secret-id salesforce-etl/jwt-credentials \
--secret-string file://secret.json
Delete secret.json afterwards. Never paste a real JWT, the private key, or an access token into a blog post, ticket, or chat.
Step 9: Create the Glue connection
In the Glue console, go to Data connections → Create connection and choose Salesforce.
- Enter the Salesforce instance URL, in the form
https://<your-domain>.my.salesforce.com. - Set the authentication type to OAuth2 with the JWT bearer grant, and use the Consumer Key from your External Client App.
- Select the IAM role from Step 7 and the secret from Step 8.
- Test the connection.
If the test fails with jti already in use, the JWT being submitted was already used. Sign a fresh one with a new jti, or remove the jti claim from the JWT. Don’t test the same token anywhere else first.
flowchart TD
E["Error: jti already in use"] --> Q1{"Is the jti different from the last attempt?"}
Q1 -->|No| F1["Use uuid4 for jti and sign a new JWT"]
Q1 -->|Yes| Q2{"Was this JWT tested anywhere else first?"}
Q2 -->|Yes| F2["Sign a separate JWT just for Glue"]
Q2 -->|No| Q3{"Did a previous save or test already succeed or retry?"}
Q3 -->|Yes| F3["Check the current connection state, then retry with a new JWT"]
Q3 -->|No| F4["Try a JWT without the jti claim as a diagnostic"]
Common errors
| Error | Likely cause | Fix |
|---|---|---|
jti already in use |
The same signed JWT reached Salesforce more than once | Sign a new JWT per attempt; don’t reuse it in tests or retries |
invalid_grant (user hasn’t approved this consumer) |
The integration user isn’t pre-authorized | Assign the user’s profile or a permission set to the app (Step 3) |
| Access denied reading the secret | Secret name doesn’t match the policy ARN | Keep the secret name and the policy ARN in sync |
| Signature or audience errors | Wrong certificate, or aud doesn’t match the login URL |
Re-upload server.crt, and use the correct login or My Domain URL |
Step 10: Prepare the target
Zero-ETL needs a destination, such as a Glue Data Catalog database backed by S3 (or a Redshift or lakehouse target). This post uses a Glue Data Catalog database named salesforce.
- Target database:
salesforce - Target S3 location:
s3://<s3_bucket_name>/<prefix>/ - Target KMS key (if encrypted):
<kms_key_arn>
Add the Data Catalog resource policy
The Data Catalog needs a resource policy that lets the integration be created against the target database and lets Glue authorize it. Without it, creating the integration fails with an authorization error.
In the Glue console, go to Data Catalog settings → Permissions, add the statements below, and save. Replace <region> and <account_id> with your values.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::<account_id>:root"
},
"Action": "glue:CreateInboundIntegration",
"Resource": [
"arn:aws:glue:<region>:<account_id>:catalog",
"arn:aws:glue:<region>:<account_id>:database/salesforce"
]
},
{
"Effect": "Allow",
"Principal": {
"Service": "glue.amazonaws.com"
},
"Action": "glue:AuthorizeInboundIntegration",
"Resource": [
"arn:aws:glue:<region>:<account_id>:catalog",
"arn:aws:glue:<region>:<account_id>:database/salesforce"
]
}
]
}
| Statement | Principal | Action | Purpose |
|---|---|---|---|
| 1 | Your AWS account | glue:CreateInboundIntegration |
Allows an integration to be created with the salesforce database as its target |
| 2 | The Glue service | glue:AuthorizeInboundIntegration |
Allows Glue to authorize that integration to write into the catalog and database |
Apply it from the CLI instead
aws glue put-resource-policy \
--region ap-southeast-1 \
--policy-in-json file://catalog-policy.json
put-resource-policy replaces the whole catalog resource policy. If your account already has one (check with aws glue get-resource-policy), merge these statements into it, or you will overwrite the existing permissions.
flowchart LR
U["You or your role"] -->|CreateInboundIntegration| CP["Data Catalog resource policy"]
G["Glue service"] -->|AuthorizeInboundIntegration| CP
CP --> DB["salesforce database"]
Step 11: Prepare the target
Zero-ETL needs a destination, such as a Glue Data Catalog database backed by S3 (or a Redshift or lakehouse target).
- Target database / S3 location:
<target details> - Target resource policy allowing the integration to write:
<policy details> - Target KMS key:
<key details>
Step 12: Create the zero-ETL integration
In the Glue console, open Zero-ETL integrations → Create integration:
- Choose Salesforce as the source and select the connection from Step 9.
- Select the Salesforce objects to replicate (for example Account, Contact, Opportunity).
- Choose the target and the IAM role.
- Set the replication frequency and review the settings.
- Create the integration.
Or from the CLI:
aws glue create-integration \
--region ap-southeast-1 \
--integration-name <integration-name> \
--source-arn <glue-connection-arn> \
--target-arn <target-database-arn>
Step 13: Verify
flowchart LR
V1["Status CREATING"] --> V2["Status ACTIVE"]
V2 --> V3["Initial load completes"]
V3 --> V4["Tables appear in target database"]
V4 --> V5["Query data in Athena"]
V5 --> V6["Change a record in Salesforce"]
V6 --> V7["Change appears after next sync"]
- The integration status moves from
CREATINGtoACTIVE. - The initial load completes, and tables appear in the target database.
- Data shows up in the target (for example via Athena).
- Changes made in Salesforce show up after the next sync.
Operating it long-term
The JWT is exchanged for an access token when the connection is created. For an integration that runs for months or years, check how Glue handles renewal, because a JWT bearer connection may need its credentials refreshed periodically. If renewal is a problem, the authorization code flow with a refresh token set to “valid until revoked” is the more hands-off option.
Either way:
- Set an alert on integration status so a failure doesn’t go unnoticed.
- Rotate the certificate before it expires.
- Keep the integration user active and its permissions stable.
Security checklist
- Store
server.keyin a secrets manager, not in your repo. - Rotate the certificate before it expires.
- Use a dedicated integration user with least-privilege permissions.
- Never log or publish the JWT, the private key, or the access token.
- Keep account IDs and secret ARN suffixes out of public posts.