Secure Your Serverless App with Cognito’s Managed Login Pages

I'm an AWS Community Builder and a Principal Software Architect I love all things CDK, Event Driven Architecture and Serverless
When you think about user authentication, you might picture wrestling with OAuth flows, wrestling with JWT tokens, or setting up a dozen redirects just to log a user in. But what if you could let AWS handle the heavy lifting for you? Enter Cognito’s managed login pages—a slick, customizable way to authenticate users without a headache.
In this post, we’ll explore how to set up AWS Cognito’s managed login pages using AWS CDK to host a lambda-rendered site. We’ll also dive into cookie-based authorization using a Lambda Authorizer, allowing you to protect specific routes without exposing any backend code. If you’re looking for a hands-on example, you can check out the code here: https://github.com/martzmakes/cognito-hosted.
Hosting the Website with Lambda Rendering and API Proxying
One unique part of my setup is that the website is Lambda-rendered, with all non-/api paths proxied. This means that all API requests happen on the same domain name via the /api resource, allowing the front-end to make relative calls to the backend without needing to define different API domains. This simplifies deployment and makes for easy blog examples that don’t require any knowledge of frontend frameworks.
The non /api routes on the apigateway trigger lambda(s) that return vanilla HTML with everything needed to render the site.
I am NOT recommending you do this in practice… but for frameworks like Next.js that support server-side rendering with lambda… you might be able to make use of this. The further ability of being able to protect certain paths with the cookie-validating lambda could add additional protection to your site.
While I wouldn’t recommend this for large-scale production apps, it’s a great way to prototype serverless apps without needing a separate frontend stack. Plus, if you’re working with a framework like Next.js, Lambda rendering can help you keep everything under the same API Gateway domain for a more seamless deployment.
Why Use Cognito Managed Login Pages?
AWS Cognito offers a fully managed user authentication and authorization service. By leveraging its hosted UI, you get:
Ease of Setup: No need to build your own login pages or OAuth flows.
Security: AWS handles security best practices out of the box.
Customization: Tailor the look and feel to match your application’s branding.
Initial Setup with CDK
When I was first creating the code for this blog post, I was using the regular CognitoUserPoolsAuthorizer construct. This construct expects a header with the Bearer id token that you get back from Cognito. By default it goes in the Authorization header, but you can change it with properties if you want. I started out with this code which is largely pulled from the documentation:
const userPool = new UserPool(this, "UserPool");
const domainPrefix = "martzmakes-example";
const domain = userPool.addDomain("CognitoDomainWithBlandingDesignManagedLogin",
{
cognitoDomain: { domainPrefix },
managedLoginVersion: ManagedLoginVersion.NEWER_MANAGED_LOGIN,
}
);
const homeUrl = `https://${clientBaseDomain}/home`;
const client = new UserPoolClient(this, "Client", {
userPool,
oAuth: {
flows: {
implicitCodeGrant: true,
},
callbackUrls: [`https://${clientBaseDomain}`, homeUrl],
},
});
domain.signInUrl(client, { redirectUri: homeUrl });
I create the userPool, add a cognitoDomain to it with ManagedLoginVersion.NEWER_MANAGED_LOGIN (which is the newer managed login page). cognitoDomain means that the domain will be hosted on an AWS URL and not your own. Then we create the UserPoolClient that the domain attaches to.
Out-of-the-box this seems like it should work… but it DOES NOT. If you deploy this as-is… when you open the login page you’ll get a non-descript error along the lines of there being a system error and to contact the administrator… which is rich since you ARE the admin. 🤦♂️
At first, I thought AWS was just messing with me. Everything should have been working. But after some furious clicking through the console, I stumbled upon the ‘Styles’ section of the App Client. Turns out, AWS simply refuses to render the login pages unless you define a branding resource—because why not add one more undocumented requirement? 😅

I manually created a style (just to see if this worked) and that was indeed what was missing. After troubleshooting, I found that the issue stemmed from the absence of CfnManagedLoginBranding. This resource is essential because it defines the branding for Cognito’s managed login UI—without it, AWS simply refuses to render the login pages. While my initial setup wasn’t based on Sebastian Sturm's post, his blog helped me identify this missing component, which ultimately resolved my issue.
// all I was missing was this...
new CfnManagedLoginBranding(this, "ManagedLoginBranding", {
userPoolId: userPool.userPoolId,
clientId: client.userPoolClientId,
returnMergedResources: true,
useCognitoProvidedValues: true,
});
With the default styles in place I have a pretty nice looking login UI:

Next, we can create the authorizer and attach it to a RestApi:
const restApi = new RestApi(this, `Api`, {
defaultCorsPreflightOptions: {
allowOrigins: Cors.ALL_ORIGINS,
},
endpointConfiguration: {
types: [EndpointType.REGIONAL],
},
});
restApi.addDomainName("domain", {
domainName: clientBaseDomain,
certificate,
});
new ARecord(this, "ARecord", { zone: hostedZone, recordName: clientBaseDomain, target: RecordTarget.fromAlias(new ApiGateway(restApi)) });
const apiFn = new NodejsFunction(this, "api", {
entry: join(__dirname, "fns/api.ts"),
runtime: Runtime.NODEJS_LATEST,
logGroup: new LogGroup(this, `/${id}ApiLogs`, { logGroupName: `/${id}-api`, removalPolicy: RemovalPolicy.DESTROY }),
architecture: Architecture.ARM_64,
});
const authorizer = new CognitoUserPoolsAuthorizer(this, `${id}UserPoolAuthorizer`, { cognitoUserPools: [userPool], });
const apiResource = restApi.root.addResource("api");
apiResource.addProxy({
anyMethod: true,
defaultIntegration: new LambdaIntegration(apiFn, { proxy: true }),
defaultMethodOptions: {
authorizer,
authorizationType: AuthorizationType.COGNITO,
},
});
const fn = new NodejsFunction(this, "site", {
entry: join(__dirname, "fns/site.ts"),
runtime: Runtime.NODEJS_LATEST,
logGroup: new LogGroup(this, `/${id}SiteLogs`, { logGroupName: `/${id}-site`, removalPolicy: RemovalPolicy.DESTROY }),
architecture: Architecture.ARM_64,
environment: {
AUTH_PREFIX: domainPrefix,
BASE_DOMAIN: clientBaseDomain,
USER_POOL_CLIENT_ID: client.userPoolClientId,
USER_POOL_ID: userPool.userPoolId,
}
});
const siteProxy = restApi.root.addProxy({
anyMethod: false, // Disables automatic handling of all methods
});
siteProxy.addMethod("GET", new LambdaIntegration(fn));
Key parts in the above code are that initially I used the CognitoUserPoolsAuthorizer and I create a proxy on the api resource that uses it. That means that all requests going to /api on the RestApi require that Authorization header with the cognito id token. Finally, we create the lambda-rendered site proxy which handles non-/api paths. That means requests to / and /home for example, will invoke that site lamdba. Later in this blog post we’ll carve out a /protected path which requires authentication in order to display.
💡 Why the id token and not the access token? The Cognito User Pool Authorizer in API Gateway expects the ID token instead of the access token because it is designed primarily for authenticating users, NOT authorizing API access. You can get more fine grained with authorization by using a lambda authorizer.
Lambda-based Site Rendering
My site lambda in this case is pretty simple. It always returns HTML in the response. The HTML includes hard-coded javascript (via script tags) that check to see if a cookie has been set. If it hasn’t it redirects to the Managed Auth page. When a user successfully signs in via the Managed Auth page… it redirects to the /home path and includes the Cognito id token in query string parameters. We take those, and store them in the cookie for use with the fetch request. It sounds like a lot but it’s fairly simple javascript.
export const handler = async () => {
return {
statusCode: 200,
headers: {
"Content-Type": "text/html", // required for proper browser rendering
"Access-Control-Allow-Origin": "*", // Required for CORS support to work
},
body: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lambda Page</title>
<!-- styles removed for brevity -->
<script type="module">
function setCookie(name, value, days) {
let expires = "";
if (days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + value + "; path=/" + expires;
}
function getCookie(name) {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return match ? match[2] : null;
}
function parseHashParams() {
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const idToken = params.get("id_token");
const expiresIn = params.get("expires_in");
if (idToken) {
setCookie("CognitoIdToken", idToken, expiresIn / 86400);
window.location.hash = "";
}
}
function updateUI() {
const idToken = getCookie("CognitoIdToken");
document.getElementById("signIn").classList.toggle("hidden", !!idToken);
document.getElementById("signOut").classList.toggle("hidden", !idToken);
}
async function fetchAPIData() {
const idToken = getCookie("CognitoIdToken");
if (!idToken) return;
try {
const response = await fetch('/api/hello', {
headers: { 'Authorization': \`Bearer \${idToken}\` }
});
const data = await response.json();
document.getElementById("api-response").textContent = JSON.stringify(data, null, 2);
} catch (error) {
document.getElementById("api-response").textContent = "Error fetching API.";
}
}
window.onload = () => {
parseHashParams();
updateUI();
fetchAPIData();
}
window.signIn = function () {
window.location.href = "https://${process.env.AUTH_PREFIX}.auth.us-east-1.amazoncognito.com/login?client_id=${process.env.USER_POOL_CLIENT_ID}&response_type=token&scope=aws.cognito.signin.user.admin+email+openid+phone+profile&redirect_uri=https%3A%2F%2F${process.env.BASE_DOMAIN}%2Fhome";
}
window.signOut = function () {
document.cookie = "CognitoIdToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
updateUI();
window.location.href = "https://${process.env.AUTH_PREFIX}.auth.us-east-1.amazoncognito.com/logout?client_id=${process.env.USER_POOL_CLIENT_ID}&response_type=token&scope=aws.cognito.signin.user.admin+email+openid+phone+profile&redirect_uri=https%3A%2F%2F${process.env.BASE_DOMAIN}";
}
</script>
</head>
<body>
<div class="container">
<h1>Welcome to a Lambda-Powered Page</h1>
<button id="signIn" class="button sign-in" onclick="signIn()">Sign In</button>
<button id="signOut" class="button sign-out hidden" onclick="signOut()">Sign Out</button>
<h2>API Response</h2>
<pre id="api-response">No data yet</pre>
</div>
</body>
</html>`,
};
};
Note: In fetchAPIData we retrieve the cookie from the document and include that as the Authorization header in the request.
Moving to Lambda-based Authorization
While the Cognito User Pool Authorizer works well for API authentication, it relies on ID tokens in headers—which doesn’t help when protecting UI routes. Browsers don’t automatically send Authorization headers in GET requests, but they do send cookies. That’s why switching to a Lambda Authorizer with cookie-based authentication makes for a much smoother experience.
By switching to a Lambda-based Authorizer with cookie-based authentication, I could:
Protect sub-routes across multiple Lambda-rendered pages.
Use the same cookies for seamless user experience.
Avoid exposing any backend code directly.
Here’s an example of how the Lambda Authorizer is set up:
const authFn = new NodejsFunction(this, "auth", {
entry: join(__dirname, "fns/auth.ts"),
runtime: Runtime.NODEJS_LATEST,
logGroup: new LogGroup(this, `/${id}authLogs`, { logGroupName: `/${id}-auth`, removalPolicy: RemovalPolicy.DESTROY }),
architecture: Architecture.ARM_64,
environment: {
AUTH_PREFIX: domainPrefix,
BASE_DOMAIN: clientBaseDomain,
USER_POOL_CLIENT_ID: client.userPoolClientId,
USER_POOL_ID: userPool.userPoolId,
},
});
const authorizerFn = new RequestAuthorizer(this, "Authorizer", {
handler: authFn,
identitySources: ["method.request.header.Cookie"],
});
The Lambda function verifies the cookie and extracts the user's identity, allowing for seamless authentication without exposing tokens.
The JWT validation is similar to what I did in my old blog post on https://martzmakes.com/creating-verifiable-json-web-tokens-jwts-with-aws-cdk … If you’re more of a visual person, I gave a talk on this at CDK Day 2021 here (yeah… I know it’s 2025… but it holds up). I’m not going to go into the specifics, but the code for the authorizer function is here: https://github.com/martzmakes/cognito-hosted/blob/main/lib/fns/auth.ts
Since we’re already storing the id token as a cookie we can modify the fetchAPIData function in the site’s script to not include the header:
async function fetchAPIData() {
try {
const response = await fetch('/api/hello');
const data = await response.json();
document.getElementById("api-response").textContent = JSON.stringify(data, null, 2);
} catch (error) {
document.getElementById("api-response").textContent = "Error fetching API.";
}
}
Furthermore we can add a /protected route on the API gateway for a lambda-rendered page that requires a user to be logged in.
const protectedFn = new NodejsFunction(this, "protected", {
entry: join(__dirname, "fns/protected.ts"),
runtime: Runtime.NODEJS_LATEST,
logGroup: new LogGroup(this, `/${id}ProtectedLogs`, { logGroupName: `/${id}-protected`, removalPolicy: RemovalPolicy.DESTROY }),
architecture: Architecture.ARM_64,
environment: {
AUTH_PREFIX: domainPrefix,
BASE_DOMAIN: clientBaseDomain,
USER_POOL_CLIENT_ID: client.userPoolClientId,
USER_POOL_ID: userPool.userPoolId,
},
});
restApi.root
.addResource("protected")
.addMethod("GET", new LambdaIntegration(protectedFn), {
authorizer: authorizerFn,
});
Demo
With all of that in place, we can open up the site and see that the API Response is “Unauthorized” because we haven’t logged in.

If we go to the PROTECTED link, we see that we get an Unauthorized response.

After signing in, we get redirected to the home route where the Cookie is stored, and the API request returns successful:

And finally… if we click the PROTECTED link, we see the users only page because that lambda-rendered route is protected with the cookie authorizer:

Wrapping It Up
AWS Cognito’s managed login pages offer a powerful, no-fuss way to authenticate users while offloading the complexity of OAuth flows and security best practices to AWS. But by combining Cognito with Lambda-based site rendering and a custom Lambda Authorizer, you get fine-grained control over authentication, seamless cookie-based authorization, and a flexible way to protect both API and UI routes—without exposing backend logic.
This setup isn’t just a cool experiment; it’s a practical approach for serverless applications that need secure, scalable authentication without introducing unnecessary client-side complexity. Whether you’re looking to lock down API routes, protect UI pages, or simply avoid dealing with OAuth headaches, this method offers a clean, effective solution.
Want to dive deeper? Grab the full implementation and try it yourself on GitHub: martzmakes/cognito-hosted. Got questions or ideas? Let’s chat in the comments or on LinkedIn! 🚀



