Sitemap

Headless Machine-to-Machine Authorization with AWS Cognito

3 min readSep 2, 2026

--

Press enter or click to view image in full size

AWS Cognito has been supporting machine-to-machine communication before, for users with a Client ID and Secret, requiring users to get tokens via OAuth which meant performing an HTTP request to the Cognito User Pool Domain. Now with AWS Cognito supporting machine-to-machine authorization without a user pool domain it is possible to get a token with Client ID and Secret via AWS SDK and CLI alone so the following describes the minimal setup to obtain and use the token as part of a simple REST API.

The Setup

Using CDK with TypeScript, setting up a Lambda behind an API Gateway that will use the AWS provided Cognito User Pools Authorizer to minimize the amount of custom code needed. The Lambda will extract the sub (i.e. in this case the Client ID) and respond with it. As CDK currently doesn’t support the machine-to-machine authorization without a user pool domain with their Cognito constructs a few tweaks are needed and as the GetClientToken API results in an Access Token which by default isn’t trusted by the Cognito User Pools Authorizer on its own, a OAuth scope also needs to be defined on the client and the API to enable using Access Tokens to successfully communicate with the API.

The Lambda

The Lambda is a pretty straightforward TypeScript-Lambda that extracts the sub and returns it as part of a message:

exports.handler = async (event: any) => {
return {
isBase64Encoded: false,
statusCode: 200,
headers: {},
body: `Hello ${event.requestContext.authorizer.claims.sub}!`,
}
}

The Stack

The Stack defines a Lambda, a Cognito User Pool with a Client that allows machine-to-machine using a scope and a REST API that makes use of the pool in its Authorizer:

const lambda = new NodejsFunction(this, 'Lambda', {
functionName: 'Lambda',
logGroup: new LogGroup(this, 'LambdaLogGroup', {
logGroupName: 'LambdaLogGroup',
removalPolicy: RemovalPolicy.DESTROY,
}),
entry: './lambda/index.ts',
handler: 'handler'
});

const userPool = new UserPool(this, 'UserPool', {
userPoolName: 'users',
removalPolicy: RemovalPolicy.DESTROY,
});

const resourceServerScope = new ResourceServerScope({
scopeName: 'read',
scopeDescription: 'API Read Access',
});
const userPoolResourceServer = userPool.addResourceServer('ResourceServer', {
identifier: 'resource-server',
scopes: [
resourceServerScope,
],
});
const authorizationScope = `${userPoolResourceServer.userPoolResourceServerId}/${resourceServerScope.scopeName}`;

const userPoolClient = userPool.addClient('Client', {
userPoolClientName: 'headless-client',
generateSecret: true,
disableOAuth: true,
});
const cfnUserPoolClient = userPoolClient.node.defaultChild as CfnUserPoolClient;
cfnUserPoolClient.addPropertyOverride('ExplicitAuthFlows', ['ALLOW_CLIENT_TOKEN_AUTH']);
cfnUserPoolClient.addPropertyOverride('AllowedOAuthScopes', [authorizationScope]);

const cognitoUserPoolsAuthorizer = new CognitoUserPoolsAuthorizer(this, 'CognitoUserPoolsAuthorizer', {
authorizerName: 'CognitoUserPoolsAuthorizer',
cognitoUserPools: [userPool]
});

const restApi = new RestApi(this, 'APIGateway');
restApi.root.addMethod('GET', new LambdaIntegration(lambda), {
authorizationType: AuthorizationType.COGNITO,
authorizer: cognitoUserPoolsAuthorizer,
authorizationScopes: [authorizationScope]
})

new Deployment(this, 'Deployment', {
api: restApi,
})

new CfnOutput(this, 'ClientId', {
key: 'ClientId',
value: userPoolClient.userPoolClientId
});

new CfnOutput(this, 'ClientSecret', {
key: 'ClientSecret',
value: userPoolClient.userPoolClientSecret.unsafeUnwrap()
})

Get Johannes Geiger’s stories in your inbox

Join Medium for free to get updates from this writer.

As mentioned before as CDK currently doesn’t support setting up the client directly the approach is to define a client that generates a secret but explicitly disables OAuth and to override the properties using the L1 construct afterwards:

const userPoolClient = userPool.addClient('Client', {
userPoolClientName: 'headless-client',
generateSecret: true,
disableOAuth: true,
});
const cfnUserPoolClient = userPoolClient.node.defaultChild as CfnUserPoolClient;
cfnUserPoolClient.addPropertyOverride('ExplicitAuthFlows', ['ALLOW_CLIENT_TOKEN_AUTH']);
cfnUserPoolClient.addPropertyOverride('AllowedOAuthScopes', [authorizationScope]);

The Demo

After deploying the Stack and extracting the URL, the Client ID and the Client Secret, first an Access Token is requested using ID, Secret and the Scope defined above:

TOKEN=$(aws cognito-idp get-client-token \
--client-id $CLIENT_ID \
--secret $CLIENT_SECRET \
--scopes resource-server/read \
| jq -r '.ClientAuthenticationResult.AccessToken')

Now just for the sake of completeness and to demo that the API is set up right, an unauthenticated request expecting an HTTP 401 is performed:

curl -v $URL

And finally using the Token that’s just been generated, an authenticated request expecting an HTTP 200 is performed:

curl -v -H "Authorization: Bearer $TOKEN" $URL

Which greets us with the Client ID that has been used to create the Access Token.

Summary

Whilst the setup in CDK is a bit clunky due to the missing L2 construct support the functionality works straightforward and removing the need to get a token indirect via the OAuth URL reduces effort and streamlines machine-to-machine communication.

--

--