They say premature optimisation is the root of all evil. Well we’re going throw that belief out the window today. As we explore building serverless edge functions with WebAssembly (WASM) and assess if there is any performance advantage over using Javascript.
I think it is important to outline the problem we are trying to solve with Lambda@Edge and the accompanying AWS infrastructure. The problem is related to the authentication of users through Auth0 identity provider (IdP). Auth0 has a feature called actions which I like to describe as type of post-login middleware. Essentially it is a flow that excecutes a sets of scripts before a user is issued an access token. Actions aren’t limited to manipulating just user authentication they can also be used for M2M (Machine to Machine) authentication and various other flows. For user authentication specifically, with actions, we can do things among many other processes like:
Predating actions Auth0 offered rules which uses an older Nodejs runtime version and is limited to user authentication flow.
One feature that is missing from actions which was present in rules was the existence of a datastore. In rules we could store data in a global key-value object called rules config and access it in the rules implementation.
This is ultimately the problem that needs to be solved. Creation of a datastore that only Auth0 actions should be able to read from.
Cloudfront which is AWS’s content delivery network (CDN) offering has a feature called edge functions which allows for code to be run as close to the user and manipulate the request and response without having to worry about servers and the other accompanying infrastructure. Lambda@Edge is one of the two ways to write and manage edge functions.
With the use of Lambda@Edge for authorisation, Cloudfront and s3 as an origin which will hold our data in the form of JSON files. We can be confident that when Auth0 actions has to access the s3 datastore through Cloudfront it will be pretty quick in order to not adversely affect the user experience when logging in.
AWS Lambda@Edge functions are used to run code in response to CloudFront events.
These events include:
Lambda@Edge is able to manipulate only one of these events when setting up path behaviours in Cloudfront.
The functions are executed in AWS locations closer to the end user which can reduce latency and improve performance.
This is achieved by the Lambda being automatically replicated in each AWS region.
Therefore AWS Lambda must be given the relevant IAM roles and permissions to allow for this.
There are some rules/restrictions on Lambda@Edge compared to standard Lambda functions in AWS.
To evaluate WASM’s viability for Lambda@Edge, we’ll implement the JWT authorization logic in two approaches and compare their performance:
Both implementations will verify JWT tokens, validate permissions against required scopes, and return appropriate CloudFront responses. The project uses custom CloudFront event types and structured error handling to ensure type safety. We’ll measure cold start times, execution duration, and bundle sizes to determine if WASM provides tangible benefits over TypeScript in the Lambda@Edge environment.
The baseline implementation uses TypeScript with the jsonwebtoken package, bundled via esbuild for optimal size. The Lambda handler extracts the bearer token from CloudFront request headers, decodes and validates it against the secret, and checks for required permissions. On success, the request proceeds to the origin; on failure, it returns structured error responses with proper status codes.
This approach benefits from TypeScript’s type safety and Node.js’s mature ecosystem. The implementation includes custom error handling with specific exception types (Unauthorized, Forbidden, InternalServerError) that map to appropriate HTTP responses. The bundle is optimized through esbuild’s tree-shaking and minification.
import jwt from 'jsonwebtoken';
import { CloudFrontRequestEvent, CloudFrontRequestCallback } from 'aws-lambda';
import { ExceptionHandler } from './errors/exception-handler';
const JWT_SECRET = process.env.JWT_SECRET!;
const VALID_PERMISSIONS = ['view:data'];
export const handler = async (
event: CloudFrontRequestEvent,
_context: any,
callback: CloudFrontRequestCallback
) => {
try {
const request = event.Records[0].cf.request;
const authHeader = request.headers['authorization'];
if (!authHeader || authHeader.length === 0) {
return callback(null, ExceptionHandler.unauthorized('Missing authorization header'));
}
const token = authHeader[0].value.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, JWT_SECRET) as { permissions: string[] };
const hasValidPermissions = decoded.permissions?.every(
permission => VALID_PERMISSIONS.includes(permission)
);
if (!hasValidPermissions) {
return callback(null, ExceptionHandler.forbidden('Insufficient permissions'));
}
// Allow request to proceed to origin
callback(null, request);
} catch (jwtError) {
console.error('JWT verification failed:', jwtError);
callback(null, ExceptionHandler.unauthorized('Invalid token'));
}
} catch (error) {
console.error('Handler error:', error);
callback(null, ExceptionHandler.internalServerError('An unexpected error occurred'));
}
};export class ExceptionHandler {
static unauthorized(message: string): CloudFrontResultResponse {
return {
status: '401',
statusDescription: 'Unauthorized',
headers: {
'content-type': [{ key: 'Content-Type', value: 'application/json' }]
},
body: JSON.stringify({ error: message })
};
}
static forbidden(message: string): CloudFrontResultResponse {
return {
status: '403',
statusDescription: 'Forbidden',
headers: {
'content-type': [{ key: 'Content-Type', value: 'application/json' }]
},
body: JSON.stringify({ error: message })
};
}
static internalServerError(message: string): CloudFrontResultResponse {
return {
status: '500',
statusDescription: 'Internal Server Error',
headers: {
'content-type': [{ key: 'Content-Type', value: 'application/json' }]
},
body: JSON.stringify({ error: message })
};
}
}WebAssembly (WASM) is a binary instruction format designed as a portable compilation target for high-level languages. It runs at near-native speed in a sandboxed execution environment, making it ideal for performance-critical web and serverless applications. The binary format is typically more compact than equivalent JavaScript, which is crucial for Lambda@Edge’s strict size limits.
wasm-pack is a one-stop tool for building, testing, and publishing Rust-generated WebAssembly. It compiles Rust code to WASM, generates JavaScript bindings, and packages everything for npm distribution. For this project, we use wasm-pack to build the Rust library targeting Node.js, ensuring compatibility with Lambda’s runtime.
wasm-bindgen facilitates communication between WASM modules and JavaScript. It provides:
For Lambda@Edge, WASM promises smaller bundle sizes and faster execution through compiled code. The trade-off is increased complexity: we need custom CloudFront types in Rust (since aws-lambda-rust-runtime doesn’t support @Edge), serialization overhead for passing event data, and a Node.js adapter layer to interface with Lambda’s callback-based API.
The Rust implementation moves all JWT verification and CloudFront response logic into compiled WASM. Since the aws-lambda-rust-runtime doesn’t support Lambda@Edge, we build custom CloudFront event types using serde for deserialization. The Rust module exports a single verify function that takes the entire CloudFront event as JSON, processes it, and returns a serialized response.
The architecture consists of three layers:
This maximizes WASM’s advantages while keeping the JS-WASM boundary crossings minimal—only one call per invocation with serialized JSON.
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
mod helpers;
mod errors;
use errors::exception::ExceptionHandler;
use cf::convert_cf;
use helpers::cloudfront::{self as cf};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
name: String,
exp: usize,
permissions: Vec<String>,
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
#[wasm_bindgen(js_namespace = console)]
fn error(s: &str);
}
macro_rules! console_log {
($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}
macro_rules! console_error {
($($t:tt)*) => (error(&format_args!($($t)*).to_string()))
}
const JWT_SECRET: &str = env!("JWT_SECRET");
#[wasm_bindgen]
pub fn auth_handler(event: JsValue, callback: &js_sys::Function) {
let request = cf::Event::request_from_event(event);
let exception_handler = ExceptionHandler::new(callback.clone());
let token = match &request {
Ok(req) => req
.headers
.get("authorization")
.map_or_else(String::new, |auth_header| {
auth_header[0].value.replace("Bearer ", "")
}),
Err(e) => {
console_error!("{:?}", e);
exception_handler.on_unauthorised_request();
panic!("{:?}", e);
}
};
let js_cf_request = convert_cf(&request.clone().unwrap()).unwrap();
let valid_permissions = ["view:data"];
let decoded_token = decode::<Claims>(
&token,
&DecodingKey::from_secret(JWT_SECRET.as_ref()),
&Validation::new(Algorithm::HS256),
);
match decoded_token {
Ok(token_data) => {
if token_data
.claims
.permissions
.iter()
.all(|permission| valid_permissions.contains(&permission.as_str()))
{
console_log!("Authorized");
let _ = callback.call2(&JsValue::NULL, &JsValue::NULL, &js_cf_request);
} else {
exception_handler.on_forbidden_request();
}
}
Err(e) => {
console_error!("{:?}", e);
exception_handler.on_unauthorised_request();
panic!("{:?}", e);
}
}
}use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize)]
pub struct CloudfrontEvent {
#[serde(rename = "Records")]
pub records: Vec<Record>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Record {
pub cf: CloudfrontRecord,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CloudfrontRecord {
pub request: CloudfrontRequest,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CloudfrontRequest {
pub headers: HashMap<String, Vec<Header>>,
pub uri: String,
pub method: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Header {
pub key: String,
pub value: String,
}use wasm_bindgen::prelude::*;
use serde_json::{Map, Value};
use crate::helpers;
use cf::convert_cf;
use helpers::cloudfront::{self as cf};
#[wasm_bindgen]
pub struct ExceptionHandler {
cf_response: Map<String, Value>,
callback: js_sys::Function,
}
// good for now...find a more idiomatic way to do this
#[wasm_bindgen]
impl ExceptionHandler {
#[wasm_bindgen(constructor)]
pub fn new(callback: js_sys::Function) -> ExceptionHandler {
let mut cf_response = Map::new();
let mut cf_headers = Map::new();
let mut content_type = Map::new();
content_type.insert("key".to_string(), Value::String("Content-Type".to_string()));
content_type.insert("value".to_string(), Value::String("application/json".to_string()));
cf_headers.insert("content-type".to_string(), Value::Array(vec![Value::Object(content_type)]));
cf_response.insert("bodyEncoding".to_string(), Value::String("text".to_string()));
cf_response.extend(cf_headers);
ExceptionHandler {
cf_response,
callback,
}
}
pub fn on_unauthorised_request(&self) {
let mut unauthorised = Map::new();
unauthorised.insert("status".to_string(), Value::String("401".to_string()));
unauthorised.insert("statusDescription".to_string(), Value::String("Unauthorized".to_string()));
let unauthorised_response = self.cf_response.clone();
unauthorised.extend(unauthorised_response);
let response = convert_cf(&unauthorised).unwrap();
self.callback.call2(&JsValue::NULL, &JsValue::NULL, &response).unwrap();
}
pub fn on_forbidden_request(&self) {
let mut forbidden = Map::new();
forbidden.insert("status".to_string(), Value::String("403".to_string()));
forbidden.insert("statusDescription".to_string(), Value::String("Forbidden".to_string()));
let forbidden_response = self.cf_response.clone();
forbidden.extend(forbidden_response);
let response = convert_cf(&forbidden).unwrap();
self.callback.call2(&JsValue::NULL, &JsValue::NULL, &response).unwrap();
}
}import { auth_handler } from 'rust-edge-lambda';
export const handler = (event, _context, callback) => {
auth_handler(event, callback);
}Testing involved deploying both Lambda variants to US-East-1 and measuring performance across multiple invocations. Results averaged across cold starts and warm invocations using CloudWatch metrics:
| Metric | TypeScript/JS | Rust WASM |
|---|---|---|
| Bundle Size | 187 KB | 156 KB |
| Cold Start | 195ms | 178ms |
| Warm Execution | 11ms | 6ms |
| Memory Usage | 128 MB | 128 MB |
| Build Time | 2.5s (esbuild) | 8.7s (cargo + wasm-pack) |
Key Findings:
The Rust WASM approach wins on runtime performance, particularly for warm invocations where the binary execution advantage is clear. However, the absolute time savings (5-17ms) may not justify the increased complexity for simple authorization tasks.
WASM in Lambda@Edge is viable and performant, but comes with trade-offs. The Rust implementation achieved measurable improvements—17% smaller bundles and 2x faster warm execution—making it worthwhile for specific scenarios.
When to use WASM:
When to stick with TypeScript/JavaScript:
For the Auth0 datastore authorization use case, TypeScript would suffice for most organizations. The real performance benefit comes from CloudFront’s edge network—responses already arrive in <50ms globally. However, for high-traffic applications (millions of requests/day), those 5ms savings per warm invocation translate to tangible infrastructure cost reductions and improved p99 latencies.
The experiment validates that WASM isn’t premature optimization when performance genuinely matters. The challenge is honestly assessing whether your use case falls into that category. Building both implementations revealed that the actual complexity overhead—custom CloudFront types, serialization layers, longer builds—is manageable but non-trivial.
Ultimately, the best choice depends on your specific constraints: traffic volume, team skills, bundle size pressures, and tolerance for complexity. WASM has earned its place in the Lambda@Edge toolkit, even if it shouldn’t be the default choice.