When creating an AWS Lambda function, AWS offers an SDK that allows us to invoke it directly from Java code. Another common approach is to integrate the Lambda function with Amazon API Gateway. This setup turns the Lambda function into an HTTP API, enabling it to be accessed through standard HTTP requests.

As Piotr mentioned, when describing how to call the AWS API gateway from Java we want to send an HTTP request. We also need to provide authentication by signing each of these requests using AWS Signature Version 4.

It turns out the same principle also applies to Lambda function URLs introduced by Amazon on the 6th of April 2022. Lack of signature in such a request usually results in a 403 Forbidden error. But, let’s start from the beginning.

What exactly is a Lambda function URL?

Lambda function URL is a convenient alternative to the Amazon API Gateway. It allows us to create a simple HTTP endpoint. This provides a quick way to invoke our Lambda function by sending the appropriate request to a dedicated URL.

https://<generated-url-id>.lambda-url.<region>.on.aws

URL id part is always generated upon creation and cannot be manually changed – once it is created, it never changes. The region part relies on the region in which the Lambda function is set up.

Configuration requires us to select one of the available auth-type settings:

  • NONE – disables the need for signing requests. This also makes the function URL public, which means that any unauthenticated client can invoke the function
  • AWS_IAM – request sender must have lambda: InvokeFunctionUrl permission in their identity-based policy or the same permission granted to them in the resource-based policy of the invoked function.

In our case, of course, we are interested in the AWS_IAM auth type setting. After creation, the Lambda function URL is ready to be used.

Calling Lambda function URL from Java code

I recently had to create a system to sign HTTP requests sent from a Java Spring Boot application to an existing Lambda function URL. Since we were already using RestTemplate for requests from the application, we decided to use it for this case as well. This allowed us to install an interceptor that would handle the signing requirements.

The last thing that remained was to choose a way to sign the request inside the interceptor. After research, I came across an API reference page describing the AwsV4HttpSigner class. This is a part of AWS SDK v2 for Java and it appeared to be a perfect fit for my needs. After the decision to push forward, I started the implementation which I will describe in the steps below.

Collect necessary data

Before proceeding to implementation, we need to gather all the data for signing logic:

  • AWS_LAMBDA_FUNCTION_URL (URL of lambda function we want to invoke with HTTP requests)
  • AWS_REGION (region of deployed lambda function)
  • AWS_ACCESS_KEY (IAM identity access key)
  • AWS_SECRET_ACCESS_KEY (IAM identity secret key)

Include AWS SDK for signing

We chose AWS SDK v2 because AWS SDK v1 will reach its end-of-support phase in December 2025. For signing purposes, we need the following dependencies:

// maven
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>auth</artifactId>
    <version>2.26.27</version>
</dependency>
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>http-auth-aws</artifactId>
    <version>2.26.27</version>
</dependency>

// gradle
implementation("software.amazon.awssdk:auth:2.26.27")
implementation("software.amazon.awssdk:http-auth-aws:2.26.27")

Implement signing logic

1. Create HTTP requests interceptor

Since we’re using RestTemplate to call the Lambda function URL, we’ll create an interceptor class that implements the ClientHttpRequestInterceptor interface. This interface includes the intercept() method, which we can override and customize to fit our needs.

@Component
public class AwsSigningInterceptor implements ClientHttpRequestInterceptor {

    private final AwsConfiguration awsConfiguration;

    public AwsSigningInterceptor(AwsConfiguration awsConfiguration) {
        this.awsConfiguration = awsConfiguration;
    }

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        return execution.execute(request, body);
    }

}

The AwsConfiguration is injected through the constructor and is a configuration properties class that contains the data mentioned in point 1. In our example, this data is retrieved from the application.properties file, but the method of retrieval can vary depending on the environment configuration.

2. Prepare request to sign

Overriding the intercept() method lets us work with the intercepted requests. We will use these to build a SdkHttpRequest object, which is the AWS SDK equivalent of the HttpRequest interface.

SdkHttpRequest sdkHttpRequest = SdkHttpRequest.builder()
        .uri(request.getURI())
        .method(SdkHttpMethod.fromValue(request.getMethod().name()))
        .putHeader("Content-Type", "application/json")
        .build();

This creates an object that contains similar request data (URI and HTTP method), but it’s specifically designed for the signing process. Note that the Content-Type header is set to application/json, which is necessary for proper communication with the Lambda function, as it responds in JSON format.

3. Sign intercepted request

In this step, it is time to introduce our main hero – AwsV4HttpSigner class instance. This is done by simply using the provided factory method AwsV4HttpSigner.create() which returns its default implementation:

AwsV4HttpSigner awsSigner = AwsV4HttpSigner.create();

We will use the sign() method, which handles all the details automatically (as explained in the AWS documentation). However, we first need to provide it with some necessary data.

AwsCredentialsIdentity object which contains accessKey and secretAccessKey:

AwsCredentialsIdentity credentialsIdentity = AwsCredentialsIdentity.create(
        awsConfiguration.getAccessKey(),
        awsConfiguration.getSecretKey()
);

Service name, which is a simple String indicating that we sign requests to Lambda function, and the region in which this function is deployed:

String serviceName = "lambda";
String region = awsConfiguration.getRegion();

Combining this with SdkHttpRequest built earlier and the intercepted request body, we are finally ready to sign the request:

SdkHttpRequest signedRequest = awsSigner.sign(r -> r.identity(credentialsIdentity)
                .request(sdkHttpRequest)
                .payload(ContentStreamProvider.fromByteArray(body))
                .putProperty(AwsV4FamilyHttpSigner.SERVICE_SIGNING_NAME, serviceName)
                .putProperty(AwsV4HttpSigner.REGION_NAME, region))
        .request();

The sign() method requires a Consumer<SignRequest.Builder> argument, which we provide as a lambda expression. The method returns a SignedRequest object, from which we retrieve the signed SdkHttpRequest by using the request() method.

4. Execute signed HTTP request

The last thing left to do is to execute the request which is signed. Before that, we need to convert it back to Spring’s HttpRequest representation. In this example, we will just instantiate it as an anonymous class and implement the needed methods:

private HttpRequest convertToHttpRequest(SdkHttpRequest request) {
    return new HttpRequest() {
        @Override
        public HttpMethod getMethod() {
            return HttpMethod.valueOf(request.method().name());
        }
        @Override
        public URI getURI() {
            return request.getUri();
        }
        @Override
        public HttpHeaders getHeaders() {
            var headers = new HttpHeaders();
            request.headers().forEach(headers::addAll);
            return headers;
        }
    };
}

In the end, we pass the converted HttpRequest and intercepted request body to execute method of a ClientHttpRequestExecution which is an argument of overridden intercept() method:

HttpRequest signedHttpRequest = convertToHttpRequest(signedRequest);
return execution.execute(signedHttpRequest, body);

After combining the above code snippets, the full method for intercepting requests and signing them looks as follows:

@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
        throws IOException {
    SdkHttpRequest sdkHttpRequest = SdkHttpRequest.builder()
            .uri(request.getURI())
            .method(SdkHttpMethod.fromValue(request.getMethod().name()))
            .putHeader("Content-Type", "application/json")
            .build();


    AwsV4HttpSigner awsSigner = AwsV4HttpSigner.create();
    AwsCredentialsIdentity credentialsIdentity = AwsCredentialsIdentity.create(
            awsConfiguration.getAccessKey(),
            awsConfiguration.getSecretKey()
    );
    String serviceName = "lambda";
    String region = awsConfiguration.getRegion();

    SdkHttpRequest signedRequest = awsSigner.sign(r -> r.identity(credentialsIdentity)
                    .request(sdkHttpRequest)
                    .payload(ContentStreamProvider.fromByteArray(body))
                    .putProperty(AwsV4FamilyHttpSigner.SERVICE_SIGNING_NAME, serviceName)
                    .putProperty(AwsV4HttpSigner.REGION_NAME, region))
            .request();

    HttpRequest signedHttpRequest = convertToHttpRequest(signedRequest);
    return execution.execute(signedHttpRequest, body);
}

Configure intercepted RestTemplate bean

With the interceptor class ready, we can use it to configure the RestTemplate bean which will be used to send requests to the Lambda function URL. We create a configuration class and inject an interceptor as an argument of the RestTemplate bean method:

@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate awsSignedRestTemplate(AwsSigningInterceptor awsSigningInterceptor) {
        return new RestTemplateBuilder().interceptors(awsSigningInterceptor).build();
    }

}

Send a signed request to Lambda function URL

Now, the configured RestTemplate bean can send a signed request to our Lambda function URL:

ResponseEntity<String> lambdaResponse = awsSignedRestTemplate.exchange(
        awsConfiguration.getLambdaFunctionUrl(),
        HttpMethod.GET,
        null,
        String.class
);
System.out.println(lambdaResponse.getStatusCode());
System.out.println(lambdaResponse.getBody());

The function used as an example returns a default response of 200 OK with „Hello from Lambda!” string as its body – a successful request results in the following statements printed in the console:

200 OK
"Hello from Lambda!"

With this approach and proper setup, we can implement request-sending logic to target the Lambda function URL without worrying about receiving 403 Forbidden responses.

Conclusion

A Lambda function URL is a good alternative to Amazon API Gateway when you need a quick way to invoke your Lambda function using HTTP requests. While signing requests may require some additional boilerplate code compared to invoking the function directly with the AWS SDK, this approach allows you to tailor the Lambda function’s behavior based on the HTTP method of the incoming request.

Java code presented in this article (and additionally its version based on AWS SDK v1) is available on GitHub.

5/5 - (8 votes)