AWS Workload Credentials Provider for AWS Lambdas, hands-on
On June 11, 2026 AWS announced the AWS Workload Credentials Provider (AWS WCP), a client-side application that provides unified access to AWS services such as SecretsManager and ACM for various Serverless deployment models (EC2, ECS, Lambda) so why not try that with AWS Lambdas? In addition to being a facade for SecretsManager, the AWS WCP caches Secrets and allows users to pre-fetch Secrets and also to use a specific IAM role that differs from the Lambdas’ role to read those Secrets.
Introduction
In the following, we will be looking at Setup, Programming Model and Runtime Characteristics of four different examples: Direct (loading secrets with the AWS SDK in a Lambda the usual way), Indirect (loading secrets via the workload credentials provider), IndirectPreFetch (loading secrets via the workload credentials provider with pre-fetching them on startup) and IndirectPreFetchAssumeRole (loading secrets via the workload credentials provider with pre-fetching them on startup using an IAM role separate from the Lambda IAM role).
All four Lambdas invoke a REST API that requires an API Key stored in a Secret, obtained with lazy loading and stored within the Lambdas’ memory for subsequent invocations. The Lambdas further handle the error case of receiving a HTTP 403 Forbidden from the API when the API Key has been “rotated” by re-loading the secret on demand before trying the request one more time to compare whether that behaviour has an effect on the Programming Model.
Setup
All four Lambdas are setup with CDK, the actual Lambda code is added further below.
Direct
The setup for the Direct Lambda is straightforward:
const directLambda = new NodejsFunction(this, 'DirectLambda', {
environment: {
'SECRET_ARN': secret.secretArn,
},
functionName: 'Direct',
logGroup: new LogGroup(this, 'DirectLogGroup', {
logGroupName: 'DirectLogGroup',
removalPolicy: RemovalPolicy.DESTROY,
}),
entry: './direct/index.ts',
handler: 'handler'
});
secret.grantRead(directLambda);No surprises here. A Lambda, the ARN of the Secret is passed in, we’re using a CDK Construct to build the Lambda from TypeScript and grant read access to the Secret.
Indirect
To setup the Indirect Lambda we first need to build the AWS Workload Credentials Provider, see the instructions at https://github.com/aws/aws-workload-credentials-provider/tree/main. As I am on a MacBook I had been using cross to cross-compile for Linux, and needed the following as Cross.toml due to a gcc issue:
[target.x86_64-unknown-linux-gnu]
image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:edge"
pre-build = [
'apt-get update && apt-get -y upgrade',
# Need to upgrade gcc to a version that does have a bug (because aws-lc-sys's custom build script errors with this:
# ### COMPILER BUG DETECTED ###
# Your compiler (cc) is not supported due to a memcmp related bug reported in https://gcc.gnu.org/bugzilla/show_bug.cgi?id=95189. We strongly recommend against using this compiler.
'apt-get -y install gcc-10 && update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-10 60',
]afterwards, build it with
cross build --release --target=x86_64-unknown-linux-gnuand we can follow the instructions for AWS Lambda including creating a Lambda Layer containing the extension.
The setup itself is then relatively straightforward again:
const layerVersion = LayerVersion.fromLayerVersionArn(this, 'LayerVersion', '<LAYER-ARN>');
const indirectLambda = new NodejsFunction(this, 'IndirectLambda', {
environment: {
'SECRET_ID': secret.secretName,
},
functionName: 'Indirect',
memorySize: 1024,
layers: [
layerVersion
],
logGroup: new LogGroup(this, 'IndirectLogGroup', {
logGroupName: 'IndirectLogGroup',
removalPolicy: RemovalPolicy.DESTROY,
}),
entry: './indirect/index.ts',
handler: 'handler'
});
secret.grantRead(indirectLambda);IndirectPreFetch
If we want to make use of the pre-fetching functionality we need to adjust the Extension configuration:
Putting the following into a config.toml file
[[capabilities.secrets_manager.prefetch.secrets]]
secret_id = "<SECRET-ARN>"adjusting the Extensions’ shell script to use it
/opt/bin/aws-workload-credentials-provider sm start --config /opt/bin/config.toml &and making sure the config file ends up in the Lambda Layer should do it.
Get Johannes Geiger’s stories in your inbox
Join Medium for free to get updates from this writer.
The main difference to the Indirect scenario is that the Lambda role needs the permission secretsmanager:BatchGetSecretValue :
const indirectLambdaPreFetch = new NodejsFunction(this, 'IndirectLambdaPreFetch', {
environment: {
'SECRET_ID': '<SECRET-ID>',
},
functionName: 'IndirectPreFetch',
memorySize: 1024,
layers: [
LayerVersion.fromLayerVersionArn(this, 'LayerVersionPreFetch', '<LAYER-ARN>')
],
logGroup: new LogGroup(this, 'IndirectPreFetchLogGroup', {
logGroupName: 'IndirectPreFetchLogGroup',
removalPolicy: RemovalPolicy.DESTROY,
}),
entry: './indirect/index.ts',
handler: 'handler'
});
indirectLambdaPreFetch.role!.attachInlinePolicy(new Policy(this, 'IndirectLambdaPreFetchRole', {
statements: [new PolicyStatement({
actions: ['secretsmanager:DescribeSecret', 'secretsmanager:GetSecretValue', 'secretsmanager:BatchGetSecretValue'],
resources: ['<SECRET-ARN>']
})]
}))The rest is identical with the Indirect scenario. Note that the Lambda role still needs to be able to read the Secret as before, as the Extension runs with the role of the Lambda.
IndirectPreFetchAssumeRole
Similar to the previous scenario, we need a config file:
[[capabilities.secrets_manager.prefetch.secrets]]
secret_id = "<SECRET-ARN>"
role_arn = "<ROLE-ARN>"This time with a Secret and a Role.
Again as the previous scenario, the base setup is similar, the permissions change though cause the Lambda no longer needs to be able to read the Secret but to assume the role:
const secretRoleArn = '<SECRET-ROLE-ARN>';
const indirectLambdaPreFetchAssumeRole = new NodejsFunction(this, 'IndirectLambdaPreFetchAssumeRole', {
environment: {
'SECRET_ID': '<SECRET-ID>',
'ROLE_ARN': secretRoleArn
},
functionName: 'IndirectPreFetchAssumeRole',
memorySize: 1024,
layers: [
LayerVersion.fromLayerVersionArn(this, 'LayerVersionPreFetchAssumeRole', '<LAYER-ARN>')
],
logGroup: new LogGroup(this, 'IndirectPreFetchAssumeRoleLogGroup', {
logGroupName: 'IndirectPreFetchAssumeRoleLogGroup',
removalPolicy: RemovalPolicy.DESTROY,
}),
entry: './indirect/index.ts',
handler: 'handler'
});
indirectLambda1024PreFetchAssumeRole.role!.attachInlinePolicy(new Policy(this, 'IndirectLambdaPreFetchAssumeRoleRole', {
statements: [
new PolicyStatement({
actions: ['sts:AssumeRole'],
resources: [secretRoleArn]
})
]
}))Programming Model
To look at the differences in the Programming Models lets look at the Direct scenario first:
async function ensureSecretLoaded() {
if (secretsManagerClient === undefined) {
secretsManagerClient = new SecretsManagerClient();
}
if (secretValue === undefined) {
const getSecretValueCommand = new GetSecretValueCommand({
SecretId: secretArn
});
const getSecretValueCommandOutput = await secretsManagerClient.send(getSecretValueCommand);
secretValue = getSecretValueCommandOutput.SecretString!
}
return secretValue;
}Here we initialize the Secrets Manager on demand and the Secret afterwards. If the request fails and we need to enforce a reload of the Secret it’s sufficient to set the secretValue to undefined and invoke ensureSecretLoaded again to reload it.
In the Indirect scenarios reloading the Secret doesn’t require invalidation and instead we force a reload via the refreshNow parameter:
async function ensureSecretLoaded(refreshNow: boolean) {
if (refreshNow || secretValue === undefined) {
const response = await fetch(`http://localhost:2773/secretsmanager/get?secretId=${secretId}${roleArn === undefined ? '' : `&roleArn=${roleArn}`}&refreshNow=${refreshNow}`, {
headers: {
'X-Aws-Parameters-Secrets-Token': awsSessionToken
}
});
secretValue = (await response.json() as GetSecretValueResponse).SecretString;
}
return secretValue;
}Here we initialize secretValue either if forced via the flag or if it’s undefined on demand, if forced we ask the Extension to reload it to make sure it’s no longer stale in the Extensions’ cache.
Runtime Characteristics
The first big difference between Direct and the Indirect scenarios is that Direct can run with minimal settings (128MB, 3s timeout) whilst the Indirect scenarios won’t even succeed in orderly fashion with that, and instead error. Using the learnings from https://medium.com/@jconning/aws-lambda-faster-is-cheaper-6bf32f58d741 and https://dev.to/neverendingqs/aws-lambda-always-allocate-at-least-1024-mb-349k I eventually used 1024MB memory as the default for all scenarios (see the CDK setup above).
When it comes to the comparison of the Cold and Warm starts between Direct, Indirect, IndirectPreFetch and IndirectPreFetchAssumeRole the tests have shown that these four scenarios are roughly splitting into two groups: The Direct scenario requires ~100MB memory, ~500ms for Cold starts and ~30–40ms for Warm starts. The Indirect scenarios require ~120–140MB memory, ~770–810ms for Cold starts and ~60–80ms for Warm starts. I would assume the higher memory usage and start times for the Indirect Scenarios originate in the Lambda Layer extension running alongside the Lambda although the differences in absolute numbers, especially compared to the Direct scenario, aren’t gigantic.
Summary
Whilst the setup of the AWS Workload Credentials Provider is not very complex, the fact that for the scenarios PreFetch and PreFetchAssumeRole the configuration needs to be baked into the Lambda Layer makes it not very flexible, the Programming Model is very similar and comes without any surprises and the Runtime Characteristics are also acceptable.
Personally the overhead of having to include the config for pre-fetching and assuming of roles wouldn’t outweigh the benefits of a unified interface for loading Secrets but if that will be supported via e.g. environment variables in the future I would be more than happy.
Appendix
direct/index.ts
import {GetSecretValueCommand, SecretsManagerClient} from '@aws-sdk/client-secrets-manager'
const secretArn = process.env.SECRET_ARN!;
let secretsManagerClient: SecretsManagerClient | undefined = undefined;
let secretValue: string | undefined;
function invalidateSecret() {
secretValue = undefined;
}
async function ensureSecretLoaded() {
if (secretsManagerClient === undefined) {
secretsManagerClient = new SecretsManagerClient();
}
if (secretValue === undefined) {
const getSecretValueCommand = new GetSecretValueCommand({
SecretId: secretArn
});
const getSecretValueCommandOutput = await secretsManagerClient.send(getSecretValueCommand);
secretValue = getSecretValueCommandOutput.SecretString!
}
return secretValue;
}
async function performRequest(url: string) {
return await fetch(url, {
headers: {
'x-api-key': await ensureSecretLoaded(),
}
})
}
exports.handler = async (event: { url: string }) => {
const response = await performRequest(event.url);
if (response.status === 403) {
invalidateSecret();
const response = await performRequest(event.url);
return response.status;
} else {
return response.status;
}
}indirect/index.ts
const awsSessionToken = process.env.AWS_SESSION_TOKEN!
const secretId = process.env.SECRET_ID!;
const roleArn = process.env.ROLE_ARN;
interface GetSecretValueResponse {
SecretString: string;
}
let secretValue: string | undefined;
async function ensureSecretLoaded(refreshNow: boolean) {
if (refreshNow || secretValue === undefined) {
const response = await fetch(`http://localhost:2773/secretsmanager/get?secretId=${secretId}${roleArn === undefined ? '' : `&roleArn=${roleArn}`}&refreshNow=${refreshNow}`, {
headers: {
'X-Aws-Parameters-Secrets-Token': awsSessionToken
}
});
secretValue = (await response.json() as GetSecretValueResponse).SecretString;
}
return secretValue;
}
async function performRequest(url: string, refreshNow: boolean) {
return await fetch(url, {
headers: {
'x-api-key': await ensureSecretLoaded(refreshNow),
}
})
}
exports.handler = async (event: { url: string }) => {
const response = await performRequest(event.url, false);
if (response.status === 403) {
const response = await performRequest(event.url, true);
return response.status;
} else {
return response.status;
}
}









