Authenticated Web App in AWS Lambda
There are always those projects that you want to implement but the right opportunity never comes along. Until it does, kind of. Abusing AWS Lambda to host a web app was something I always wanted to try but just serving a simple static website was not going to scratch the itch so I waited until something showed up. It was an internal application, only for developers, to easily explore the log files uploaded to S3. And it had the four requirements that matched my wish list:
- Simple frontend
- AWS service in the backend
- Authenticated backend access
- Sparse usage
To make the situation even better our team had just agreed, after a lot of pushing from yours truly, to do an XP (eXtreme Programming) sprint with whatever backlog tasks we wanted to prioritise.
The frontend
The frontend needed to be small and lightweight, and ideally easy to navigate once deployed to ease debugging, vanilla JavaScript to the rescue here. Esbuild will still be used to add any dependencies to the deployment, the output will be bundled into the backend build.
The backend
Again a typical JavaScript lambda handler. To keep it lean this api server is not going to run any express library or the likes, we keep it tiny. A manual handler to do 4 things:
- on /index.html return the index.html page
const html = fs.readFileSync('./index.html');
response = {
statusCode: 200,
body: html.toString(),
headers: { 'Content-Type': 'text/html' },
};- on /script.js return the bundled JavaScript for the frontend
const js = fs.readFileSync('./script.js');
response = {
statusCode: 200,
body: js.toString(),
headers: { 'Content-Type': 'text/javascript' },
};- on /api without query parameters return a list of allowed buckets to explore This is a hardcoded list within the lambda code itself.
response = {
statusCode: 200,
body: JSON.stringify(buckets),
headers: { 'Content-Type': 'application/json' },
};- on /api with a valid bucket as query parameter return a list of files for that bucket. This endpoint can be slow as it iterates through all the pages. We simplified enormously here because with our data we were never going to hit the 6 MB response limit and timeout from lambda.
const getPaginatedList = (bucket, continuationToken = undefined) => {
const command = new ListObjectsV2Command({
Bucket: bucket,
ContinuationToken: continuationToken,
});
return client.send(command).then(items => {
if (items.IsTruncated) {
console.log('Paginated');
return getPaginatedList(bucket, items.NextContinuationToken).then(
newItems => [...items.Contents, ...newItems]
);
} else {
return items.Contents;
}
});
};- on /api with a valid bucket and file key as query parameters return a presigned url to download the file
const command = new GetObjectCommand({ Bucket: bucket, Key: file });
const url = await getSignedUrl(client, command, { expiresIn: 300 });
response = {
statusCode: 200,
body: url,
expiresIn: 300,
};Very naive implementation for a simple application. But it needs to be secured.
Authentication
For authentication we chose our organization Entra ID. We already had deployed internal applications leveraging Entra ID with the authentication built into the browser, or using Cognito, but not yet directly embedded in the backend.
Get Jorge Hermoso’s stories in your inbox
Join Medium for free to get updates from this writer.
For the frontend we used the ‘@azure/msal-browser’ package, straightforward to implement, the user gets a nice Microsoft login modal, nothing interesting here.
For the backend we used the JWKS client from ‘jwks-rsa’ package to retrieve signing keys from the Microsoft JWKS endpoint and the ‘jsonwebtoken’ package to decode and verify the token.
const authenticate = token =>
new Promise(resolve => {
const decoded = jsonwebtoken.decode(token, { complete: true });
const verifyOptions = {
algorithms: ['RS256'],
audience: applicationID,
issuer: `https://login.microsoftonline.com/${tenantID}/v2.0`,
};
const client = jwksClient({
jwksUri: 'https://login.microsoftonline.com/common/discovery/keys',
});
const getKey = (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
if (err) {
callback(err, null);
} else {
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
}
});
};
jsonwebtoken.verify(token, getKey, verifyOptions, function (err, decoded) {
if (
err ||
decoded.aud !== applicationID ||
decoded.tid !== tenantID ||
decoded.iss !== `https://login.microsoftonline.com/${tenantID}/v2.0` ||
!decoded.roles ||
!decoded.roles.includes('browse') ||
decoded.exp < Math.round(Date.now() / 1000)
) {
resolve(false);
return;
}
resolve(true);
});
});There are a few issues with the code, alright. Some of them related to the fact that this is a serverless function (i.e. creating a JWKS client for every call), others more generic (like dealing with malformed tokens) we overlooked them for our first implementation. And the result worked!
Building and debugging
Using esbuild is quite straightforward.
"scripts": {
"dev": "esbuild index.js --bundle --outfile=dist/index.js --loader:.html=text --watch --servedir=dist && cp index.html dist/",
"build": "esbuild index.js --bundle --outfile=dist/script.js --loader:.html=text && esbuild backend/index.js --external:@aws* --bundle --platform=node --outfile=dist/index.js && cp index.html dist"
},Final touches
Create the Entra ID application and copy the necessary information to plug in the serverless application: applicationID and tenantID. Configure the logout URL on the Entra side.
To keep it simple the first deployment use the AWS Lambda function URLs, you should be getting started in no time.
And last but not least, review the code, this implementation is a couple of years old, and some of the packages have been updated since.
Conclusion
It can be done! AWS Lambda can be fully used as a webhosting platform, obviously it is not the right call for 99% of cases, but if you don’t have a kubernetes cluster with KEDA or similar, and you have a barely used application, you might consider this as an option.
It is very rewarding to be able to build something different in less than a week, from idea to implementation.





