DEV Community

Vinicius Senger
Vinicius Senger

Posted on

Goodbye localhost, hello AWS: adding security to re:Money

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)│    │          │
                                                        └───────────┘    └──────────┘
Enter fullscreen mode Exit fullscreen mode

We protect re:Money at two layers:

  1. API Gateway JWT authorizer — REST API routes (/entryResource/*, /csv-import/*) require a valid Cognito JWT in the Authorization header. No token? 401 before Lambda even wakes up.
  2. Server-side cookie check — The Qute-rendered UI pages (/ui/*) are public at the gateway level (they have to be — browsers don't send Authorization headers on page navigation). Instead, the Java resource checks for an id_token cookie 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:

  1. Go to Google Cloud Console → Credentials
  2. Create a new project (or use an existing one)
  3. Click Create Credentials → OAuth 2.0 Client ID
  4. Application type: Web application
  5. Leave the redirect URI blank for now — we'll come back after creating the Cognito domain
  6. 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
Enter fullscreen mode Exit fullscreen mode

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. Use https://localhost/ui/callback for the first deploy, then update with the actual API Gateway URL from the stack outputs.

Why /ui/callback and 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/callback endpoint 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
})();
Enter fullscreen mode Exit fullscreen mode

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);
    };
})();
Enter fullscreen mode Exit fullscreen mode

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);
        }
    });
})();
Enter fullscreen mode Exit fullscreen mode

Include it in every Qute template:

<script src="/js/auth.js"></script>
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Key details:

  • @CookieParam("id_token") reads the cookie set by auth.js
  • Return type changes from TemplateInstance to Object so we can return either a template or a redirect Response
  • requireAuth() returns null if authenticated, or a redirect Response to Cognito login
  • The redirect_uri points to /ui/callback — not /ui — to avoid the redirect loop
  • The /ui/callback endpoint has no auth check — it's a bare HTML page that loads auth.js

Why not use uriInfo.getBaseUri()? Inside Lambda behind API Gateway, getBaseUri() returns an internal URL (like http://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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For subsequent deploys, SAM remembers the parameters:

./build-lambda.sh && sam deploy
Enter fullscreen mode Exit fullscreen mode

Step 8: Test the Flow

  1. Open your API Gateway URL + /ui
  2. You should be redirected to Google sign-in
  3. Sign in with your Google account
  4. You'll briefly see "Signing in..." on the callback page
  5. You're redirected to re:Money with the dashboard loaded
  6. Check the menu bar — you should see your email and a Logout button
  7. Open browser DevTools → Application → Cookies → verify id_token is set
  8. 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.zip artifact
  • Still pay-per-request pricing
  • No VPC, no security groups, no network config
  • No new Java dependencies — just @CookieParam and Response.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:

  1. 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/callback endpoint that serves a bare HTML page (no auth check) where auth.js can capture the token, set the cookie, and then redirect to /ui.

  2. Circular dependency in CloudFormation — You can't use !Sub "${ReMoneyHttpApi}" in the UserPoolClient's CallbackURLs because the API references the client (for its authorizer) and the client would reference the API. Solution: use https://localhost/ui/callback for the first deploy, then hardcode the actual API Gateway URL after you get it from the stack outputs.

  3. redirect_mismatch error — The redirect_uri in the login request must exactly match one of the CallbackURLs registered 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.

  4. Cookies need Secure flag — API Gateway endpoints are HTTPS, so the cookie must have Secure to be sent back. SameSite=Lax allows the cookie to be sent on top-level navigations (which is what we need for page loads).

  5. build-lambda.sh artifact name — If your Maven artifactId changes, 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.com with Route 53 + ACM
  • Multi-tenancy — using the Cognito user ID to isolate data per user in DynamoDB
  • CI/CD — automating ./build-lambda.sh && sam deploy with 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)