In the first part of this series, we took re:Money from localhost to production with sam build && sam deploy. Two commands, zero servers, and a fully working financial tracking app on AWS Lambda.
But there's a problem: anyone with the URL can see your data.
That API Gateway endpoint we got? It's public. No login, no authentication, no authorization. For a personal finance app, that's not acceptable. Time to fix it.
In this article, we'll add Amazon Cognito with Google sign-in to re:Money — so only you (or people you invite) can access the app.
What We're Building
┌──────────┐ ┌──────────────┐ ┌─────────────┐ ┌────────┐ ┌──────────┐
│ Browser │────▶│ Google Login │────▶│ Cognito │────▶│ JWT │ │ │
│ │ │ (OAuth 2.0) │ │ User Pool │ │ Token │ │ │
└──────────┘ └──────────────┘ └─────────────┘ └───┬────┘ │ │
│ │ │
▼ │ │
┌───────────┐ │ │
│ API │──▶ │ Lambda │──▶ DynamoDB
│ Gateway │ │(Quarkus) │
│ (JWT Auth)│ │ │
└───────────┘ └──────────┘
We protect re:Money at two layers:
-
API Gateway JWT authorizer — REST API routes (
/entryResource/*,/csv-import/*) require a valid Cognito JWT in theAuthorizationheader. No token? 401 before Lambda even wakes up. -
Server-side cookie check — The Qute-rendered UI pages (
/ui/*) are public at the gateway level (they have to be — browsers don't sendAuthorizationheaders on page navigation). Instead, the Java resource checks for anid_tokencookie and redirects to Cognito login if it's missing.
The glue between these two layers is a small JavaScript file (auth.js) that stores the Cognito token in both localStorage (for fetch() calls) and a cookie (for server-side checks).
Step 1: Get Google OAuth Credentials
Before touching AWS, you need a Google OAuth client:
- Go to Google Cloud Console → Credentials
- Create a new project (or use an existing one)
- Click Create Credentials → OAuth 2.0 Client ID
- Application type: Web application
- Leave the redirect URI blank for now — we'll come back after creating the Cognito domain
- Save your Client ID and Client Secret
Step 2: Update template.yaml
Here's the full picture. We add Cognito resources, a JWT authorizer on API Gateway, and split routes into protected (API) and public (UI):
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: re:Money Quarkus Lambda Application with Cognito Auth
Parameters:
GoogleClientId:
Type: String
Description: Google OAuth Client ID
NoEcho: true
GoogleClientSecret:
Type: String
Description: Google OAuth Client Secret
NoEcho: true
Globals:
Function:
Timeout: 30
MemorySize: 512
Runtime: java21
Environment:
Variables:
QUARKUS_PROFILE: prod
Resources:
# --- Cognito ---
ReMoneyUserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: remoney-users
AutoVerifiedAttributes:
- email
Schema:
- Name: email
Required: true
Mutable: true
ReMoneyUserPoolDomain:
Type: AWS::Cognito::UserPoolDomain
Properties:
Domain: remoney-auth # Must be globally unique
UserPoolId: !Ref ReMoneyUserPool
GoogleIdentityProvider:
Type: AWS::Cognito::UserPoolIdentityProvider
Properties:
UserPoolId: !Ref ReMoneyUserPool
ProviderName: Google
ProviderType: Google
ProviderDetails:
client_id: !Ref GoogleClientId
client_secret: !Ref GoogleClientSecret
authorize_scopes: "openid email profile"
AttributeMapping:
email: email
name: name
username: sub
ReMoneyUserPoolClient:
Type: AWS::Cognito::UserPoolClient
DependsOn: GoogleIdentityProvider
Properties:
ClientName: remoney-web
UserPoolId: !Ref ReMoneyUserPool
GenerateSecret: false
SupportedIdentityProviders:
- Google
AllowedOAuthFlows:
- implicit
AllowedOAuthScopes:
- openid
- email
- profile
AllowedOAuthFlowsUserPoolClient: true
CallbackURLs:
- https://YOUR-API-ID.execute-api.us-east-1.amazonaws.com/ui/callback
- https://localhost/ui/callback
LogoutURLs:
- https://YOUR-API-ID.execute-api.us-east-1.amazonaws.com/ui/callback
- https://localhost/ui/callback
Note on CallbackURLs: You can't use
!Sub "${ReMoneyHttpApi}"here because it creates a circular dependency — the API references the UserPoolClient for its authorizer, and the client would reference the API for its callback URL. Usehttps://localhost/ui/callbackfor the first deploy, then update with the actual API Gateway URL from the stack outputs.Why
/ui/callbackand not/ui? This is critical. Cognito's implicit flow returns the token in the URL fragment (#id_token=...). Fragments are client-side only — the server never sees them. If Cognito redirects to/ui, the server-side auth check sees no cookie, and redirects back to Cognito — an infinite loop. The/ui/callbackendpoint serves a minimal page with no auth check, giving JavaScript a chance to capture the token and set the cookie before navigating to/ui.
Next, the API Gateway with a JWT authorizer:
# --- API Gateway with JWT Auth ---
ReMoneyHttpApi:
Type: AWS::Serverless::HttpApi
Properties:
Auth:
DefaultAuthorizer: CognitoAuthorizer
Authorizers:
CognitoAuthorizer:
AuthorizationScopes:
- openid
IdentitySource: $request.header.Authorization
JwtConfiguration:
issuer: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${ReMoneyUserPool}"
audience:
- !Ref ReMoneyUserPoolClient
And the Lambda function with split routes — protected API vs public UI:
# --- Lambda ---
ReMoneyFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: target/function.zip
Handler: io.quarkus.amazon.lambda.runtime.QuarkusStreamHandler::handleRequest
MemorySize: 1024
Events:
# Protected API routes (JWT required)
EntryApi:
Type: HttpApi
Properties:
ApiId: !Ref ReMoneyHttpApi
Path: /entryResource/{proxy+}
Method: ANY
CsvImportApi:
Type: HttpApi
Properties:
ApiId: !Ref ReMoneyHttpApi
Path: /csv-import/{proxy+}
Method: ANY
# Public UI routes (no gateway auth — server checks cookie)
PublicUI:
Type: HttpApi
Properties:
ApiId: !Ref ReMoneyHttpApi
Path: /ui/{proxy+}
Method: ANY
Auth:
Authorizer: NONE
PublicUIRoot:
Type: HttpApi
Properties:
ApiId: !Ref ReMoneyHttpApi
Path: /ui
Method: ANY
Auth:
Authorizer: NONE
# Static assets
PublicCss:
Type: HttpApi
Properties:
ApiId: !Ref ReMoneyHttpApi
Path: /css/{proxy+}
Method: GET
Auth:
Authorizer: NONE
PublicJs:
Type: HttpApi
Properties:
ApiId: !Ref ReMoneyHttpApi
Path: /js/{proxy+}
Method: GET
Auth:
Authorizer: NONE
The key insight: /entryResource/* and /csv-import/* use the default JWT authorizer. /ui/*, /css/*, and /js/* override with Authorizer: NONE so the browser can load pages and static assets. The UI pages are protected at the application level instead.
Step 3: Update the Google Redirect URI
After the first deploy, go back to Google Cloud Console and add the Cognito redirect URI:
https://remoney-auth.auth.us-east-1.amazoncognito.com/oauth2/idpresponse
Replace remoney-auth with your domain and us-east-1 with your region.
Step 4: The Auth JavaScript — auth.js
This is the client-side glue. It handles the Cognito OAuth callback, stores the token, and bridges the gap between browser navigation and API calls.
Create src/main/resources/META-INF/resources/js/auth.js:
// re:Money Auth - Cognito + Google sign-in
const AUTH_CONFIG = {
cognitoDomain: 'remoney-auth.auth.us-east-1.amazoncognito.com',
clientId: 'YOUR_COGNITO_CLIENT_ID', // From SAM outputs
redirectUri: window.location.origin + '/ui/callback',
provider: 'Google'
};
function remoneyLogin() {
window.location.href =
'https://' + AUTH_CONFIG.cognitoDomain + '/oauth2/authorize?' +
'response_type=token&client_id=' + AUTH_CONFIG.clientId +
'&redirect_uri=' + encodeURIComponent(AUTH_CONFIG.redirectUri) +
'&scope=openid+email+profile&identity_provider=' + AUTH_CONFIG.provider;
}
Note the redirectUri points to /ui/callback, not /ui. This is essential to avoid the redirect loop (more on that in the Gotchas section).
Cookie management — this is what makes server-side auth work. After Cognito redirects back with a token in the URL fragment, we store it in both localStorage and a cookie:
function setAuthCookie(token, maxAgeSec) {
document.cookie = 'id_token=' + token +
';path=/;max-age=' + maxAgeSec + ';SameSite=Lax;Secure';
}
function clearAuthCookie() {
document.cookie = 'id_token=;path=/;max-age=0';
}
function remoneyLogout() {
localStorage.removeItem('id_token');
localStorage.removeItem('token_expiry');
clearAuthCookie();
window.location.href =
'https://' + AUTH_CONFIG.cognitoDomain + '/logout?' +
'client_id=' + AUTH_CONFIG.clientId +
'&logout_uri=' + encodeURIComponent(AUTH_CONFIG.redirectUri);
}
function getToken() {
var expiry = localStorage.getItem('token_expiry');
if (expiry && Date.now() > parseInt(expiry)) {
localStorage.removeItem('id_token');
localStorage.removeItem('token_expiry');
clearAuthCookie();
return null;
}
return localStorage.getItem('id_token');
}
Token capture and redirect — Cognito uses the implicit flow, which puts the token in the URL fragment (#id_token=...). We parse it on the callback page and redirect to the main UI:
(function handleCallback() {
var hash = window.location.hash.substring(1);
if (!hash) return;
var params = new URLSearchParams(hash);
var token = params.get('id_token');
var expiresIn = params.get('expires_in');
if (token) {
var ttl = expiresIn ? parseInt(expiresIn) : 3600;
localStorage.setItem('id_token', token);
localStorage.setItem('token_expiry', String(Date.now() + ttl * 1000));
setAuthCookie(token, ttl);
// Redirect to the main UI now that cookie is set
window.location.href = window.location.origin + '/ui';
return;
}
})();
The key line is window.location.href = window.location.origin + '/ui'. After setting the cookie, we navigate to /ui — and this time the server-side check will find the cookie and render the dashboard.
Fetch interceptor — patches window.fetch so every AJAX call includes the Authorization header. This is what protects the /entryResource/* API calls made from the UI:
(function patchFetch() {
var originalFetch = window.fetch;
window.fetch = function(url, options) {
var token = getToken();
if (token) {
options = options || {};
options.headers = Object.assign({}, options.headers, {
'Authorization': 'Bearer ' + token
});
}
return originalFetch.call(this, url, options);
};
})();
Auth UI — dynamically adds a login or logout button to the menu bar, and shows the user's email when logged in:
(function renderAuthUI() {
document.addEventListener('DOMContentLoaded', function() {
var menuBar = document.querySelector('.menu-bar');
if (!menuBar) return;
var token = getToken();
var el = document.createElement('div');
el.className = 'menu-separator';
menuBar.appendChild(el);
if (token) {
try {
var payload = JSON.parse(atob(token.split('.')[1]));
var userEl = document.createElement('span');
userEl.className = 'menu-item';
userEl.style.cursor = 'default';
userEl.innerHTML = '<span class="menu-icon">👤</span><span class="menu-label">' +
(payload.email || 'User') + '</span>';
menuBar.appendChild(userEl);
} catch(e) {}
var logoutEl = document.createElement('a');
logoutEl.href = '#';
logoutEl.className = 'menu-item';
logoutEl.innerHTML = '<span class="menu-icon">🚪</span><span class="menu-label">Logout</span>';
logoutEl.onclick = function(e) { e.preventDefault(); remoneyLogout(); };
menuBar.appendChild(logoutEl);
} else {
var loginEl = document.createElement('a');
loginEl.href = '#';
loginEl.className = 'menu-item';
loginEl.innerHTML = '<span class="menu-icon">🔐</span><span class="menu-label">Sign in with Google</span>';
loginEl.onclick = function(e) { e.preventDefault(); remoneyLogin(); };
menuBar.appendChild(loginEl);
}
});
})();
Include it in every Qute template:
<script src="/js/auth.js"></script>
Step 5: Server-Side Auth Check in ExpenseUIResource
Here's the problem with server-rendered pages: when a user navigates to /ui, the browser makes a plain GET request. There's no Authorization header, no way for API Gateway to validate a JWT. That's why /ui/* routes are set to Authorizer: NONE.
But we can't leave them completely unprotected. The solution: check for the id_token cookie that auth.js sets after login. If it's missing, redirect to Cognito.
We also need a callback endpoint — a minimal page with no auth check that loads auth.js so it can capture the token from the URL fragment:
@Path("/ui")
public class ExpenseUIResource {
private static final String COGNITO_DOMAIN = "remoney-auth.auth.us-east-1.amazoncognito.com";
private static final String CLIENT_ID = "YOUR_COGNITO_CLIENT_ID";
private static final String IDP = "Google";
@CookieParam("id_token")
String idToken;
private Response requireAuth() {
if (idToken != null && !idToken.isEmpty()) {
return null; // authenticated
}
String redirectUri = "https://YOUR-API-ID.execute-api.us-east-1.amazonaws.com/ui/callback";
String loginUrl = "https://" + COGNITO_DOMAIN
+ "/oauth2/authorize?response_type=token&client_id=" + CLIENT_ID
+ "&redirect_uri=" + java.net.URLEncoder.encode(
redirectUri, java.nio.charset.StandardCharsets.UTF_8)
+ "&scope=openid+email+profile&identity_provider=" + IDP;
return Response.seeOther(URI.create(loginUrl)).build();
}
// OAuth callback — no auth check, just loads auth.js
@GET
@Path("/callback")
@Produces(MediaType.TEXT_HTML)
public String authCallback() {
return "<!DOCTYPE html><html><head><meta charset=\"UTF-8\">"
+ "<title>Signing in...</title></head>"
+ "<body><p>Signing in...</p>"
+ "<script src=\"/js/auth.js\"></script></body></html>";
}
@GET
@Produces(MediaType.TEXT_HTML)
public Object getDashboard(/* ... params ... */) throws Exception {
Response authRedirect = requireAuth();
if (authRedirect != null) return authRedirect;
// ... render dashboard as before ...
}
// Same pattern for all other GET endpoints:
// getCategories(), getAccounts(), getPivotTable(), getCsvImport(), getSetup()
}
Key details:
-
@CookieParam("id_token")reads the cookie set byauth.js - Return type changes from
TemplateInstancetoObjectso we can return either a template or a redirectResponse -
requireAuth()returnsnullif authenticated, or a redirectResponseto Cognito login - The
redirect_uripoints to/ui/callback— not/ui— to avoid the redirect loop - The
/ui/callbackendpoint has no auth check — it's a bare HTML page that loadsauth.js
Why not use
uriInfo.getBaseUri()? Inside Lambda behind API Gateway,getBaseUri()returns an internal URL (likehttp://localhost/), not the actual API Gateway domain. You need to hardcode the API Gateway URL or pass it via an environment variable.
Step 6: The Complete Auth Flow
Here's what happens end-to-end:
1. User opens https://YOUR-API.execute-api.us-east-1.amazonaws.com/ui
2. API Gateway: /ui route has Auth: NONE → passes through to Lambda
3. Lambda (ExpenseUIResource.getDashboard): checks for id_token cookie
→ NOT FOUND → Returns 303 redirect to Cognito
4. Browser redirects to:
https://remoney-auth.auth.us-east-1.amazoncognito.com/oauth2/authorize?
response_type=token&client_id=...
&redirect_uri=.../ui/callback ← note: /ui/callback, not /ui
&identity_provider=Google
5. Cognito redirects to Google sign-in
6. User signs in with Google → Google redirects back to Cognito
7. Cognito redirects back to:
https://YOUR-API.execute-api.us-east-1.amazonaws.com/ui/callback#id_token=eyJ...
8. API Gateway: /ui/callback matches /ui/{proxy+} with Auth: NONE → Lambda
9. Lambda (ExpenseUIResource.authCallback): returns minimal HTML page
→ No auth check — just loads auth.js
10. auth.js runs on the callback page:
- Parses id_token from URL fragment
- Stores in localStorage + sets id_token cookie
- Redirects to /ui
11. Browser navigates to /ui → sends cookie with request
12. Lambda (ExpenseUIResource.getDashboard): checks for id_token cookie
→ FOUND → Renders the dashboard
13. Any fetch() call to /entryResource/* includes Authorization: Bearer header
→ API Gateway validates JWT → Lambda processes request
The callback page is the key piece. It breaks the redirect loop by giving JavaScript a place to run before the server-side auth check kicks in.
Step 7: Deploy
First deploy (with Google credentials):
./build-lambda.sh
sam deploy --parameter-overrides \
GoogleClientId=your-google-client-id \
GoogleClientSecret=your-google-client-secret
After the first deploy, note the API Gateway URL from the outputs and update the CallbackURLs in template.yaml to use /ui/callback. Then deploy again:
sam build && sam deploy
For subsequent deploys, SAM remembers the parameters:
./build-lambda.sh && sam deploy
Step 8: Test the Flow
- Open your API Gateway URL +
/ui - You should be redirected to Google sign-in
- Sign in with your Google account
- You'll briefly see "Signing in..." on the callback page
- You're redirected to re:Money with the dashboard loaded
- Check the menu bar — you should see your email and a Logout button
- Open browser DevTools → Application → Cookies → verify
id_tokenis set - Open Network tab → verify
Authorization: Bearer ...on fetch calls to/entryResource/*
What Changed from Part 1
| Before (Part 1) | After (Security Part) |
|---|---|
| Public API Gateway | JWT-protected API routes |
| No authentication | Google sign-in via Cognito |
| Anyone can access data | Cookie check on UI, JWT check on API |
| No user identity | JWT carries user email |
sam deploy |
sam deploy --parameter-overrides (first time) |
And what didn't change:
- Still two commands to deploy
- Still zero servers to manage
- Still a single
function.zipartifact - Still pay-per-request pricing
- No VPC, no security groups, no network config
- No new Java dependencies — just
@CookieParamandResponse.seeOther()
Cost Impact
| Service | Cost |
|---|---|
| Cognito | Free tier: 50,000 MAU (monthly active users) |
| API Gateway | Same as before — free tier covers it |
| Lambda | Same as before |
| DynamoDB | Same as before |
| Total | Still ~$0/month for personal use |
Gotchas We Hit
A few things that tripped us up during implementation:
Redirect loop on
/ui— This was the biggest one. Cognito's implicit flow returns the token in the URL fragment (#id_token=...). Fragments are client-side only — the server never sees them. If Cognito redirects back to/ui, the server checks for a cookie, finds nothing, and redirects back to Cognito. Infinite loop. The fix: use a dedicated/ui/callbackendpoint that serves a bare HTML page (no auth check) whereauth.jscan capture the token, set the cookie, and then redirect to/ui.Circular dependency in CloudFormation — You can't use
!Sub "${ReMoneyHttpApi}"in the UserPoolClient'sCallbackURLsbecause the API references the client (for its authorizer) and the client would reference the API. Solution: usehttps://localhost/ui/callbackfor the first deploy, then hardcode the actual API Gateway URL after you get it from the stack outputs.redirect_mismatcherror — Theredirect_uriin the login request must exactly match one of theCallbackURLsregistered in Cognito — character for character. Inside Lambda,uriInfo.getBaseUri()returns an internal URL, not the API Gateway domain. Solution: hardcode the API Gateway URL in the Java resource.Cookies need
Secureflag — API Gateway endpoints are HTTPS, so the cookie must haveSecureto be sent back.SameSite=Laxallows the cookie to be sent on top-level navigations (which is what we need for page loads).build-lambda.shartifact name — If your MavenartifactIdchanges, the zip script breaks. Make sure the jar name in the script matches what Maven produces.
What's Next
Your app is now deployed and secured. In the next parts of this series, we could cover:
-
Custom domain — putting re:Money behind
money.yourdomain.comwith Route 53 + ACM - Multi-tenancy — using the Cognito user ID to isolate data per user in DynamoDB
-
CI/CD — automating
./build-lambda.sh && sam deploywith GitHub Actions - Restricting access — adding a Cognito Pre-Sign-Up Lambda trigger to allow only specific email addresses
But for now, you've gone from "anyone can see my bank transactions" to "Google sign-in required" — with auth handled at two layers: API Gateway for REST calls, and a simple cookie check for server-rendered pages. No new dependencies, no auth framework, just Cognito + a cookie + a callback page + 10 lines of Java.
This is Part 2 of the "Goodbye Localhost, Hello AWS" series. Part 1 covers the initial deployment. re:Money is open source as part of the Renascence Computing Project.
Top comments (0)