Introduction
When building an internal AI assistant, getting the model to generate useful responses is only one part of the engineering challenge.
The application also needs to establish who is using it, verify that the user is authenticated, and prevent unauthenticated requests from reaching the backend.
This becomes particularly important when the frontend is a static JavaScript application. Unlike a traditional server-rendered application, a static frontend does not have a private backend in which to keep credentials or client secrets. Its JavaScript, configuration, and network requests are visible to anyone who can access the application.
For the Daara Media AI Creative Content Assistant, I wanted staff to authenticate before using the application, and I wanted the API to reject unauthorized requests independently of the frontend.
I implemented this using four key technologies:
- Amazon Cognito for user authentication and managed login.
- OAuth 2.0 Authorization Code Flow with PKCE for secure authentication from a browser-based public client.
- Amazon API Gateway JWT authorizer for validating access tokens at the API boundary.
- AWS Lambda for executing the protected backend workflow after the request has been authorized.
This article explains how these components work together, why each security boundary matters, and what I learned while integrating and testing the authentication flow.
1. The security problem
My frontend is a static HTML, CSS, and JavaScript application. It does not have a server-side session layer that can securely hold a client secret.
A naive implementation might hide the chat interface behind a login screen and check whether a user is signed in using JavaScript.
However, hiding a button or redirecting unauthenticated users is not sufficient security.
A user can bypass frontend checks and send an HTTP request directly to the API using a command-line tool, a script, or an API client.
For example:
curl -X POST \ "https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/chat" \ -H "Content-Type: application/json" \ -d '{"prompt":"Write a caption for our campaign"}' If the backend does not enforce authentication, the request could reach the application even when the frontend requires login.
The fundamental design principle is therefore:
The frontend controls the user experience. The backend enforces access to protected resources.
2. The authentication architecture
The first step was to separate authentication from API authorization.
Authentication answers the question:
Who has successfully signed in?
Authorization at the API boundary answers:
Does this request present a valid token that satisfies the API's authorization requirements?
The architecture looks like this:
Staff User | v Static JavaScript UI | | Redirect to sign in v Amazon Cognito Managed Login | | Authorization Code | + PKCE v Static JavaScript UI | | Access token v API Gateway | v JWT Authorizer | +--------+--------+ | | Invalid token Valid token | | v v Reject Chat Lambda | v Bedrock Knowledge Base | v Claude Sonnet 4.5 | v Draft + Sources This diagram shows two distinct security boundaries.
First, Cognito authenticates the staff user and participates in the OAuth authorization flow.
Second, API Gateway validates the access token before allowing the protected request to reach Lambda.
The frontend does not need to be trusted to enforce this second boundary.
3. Why Amazon Cognito?
I chose Amazon Cognito because the application needed a managed identity service rather than a custom authentication implementation.
Cognito provides a user pool for managing users and authentication, as well as a hosted sign-in experience through its managed login feature.
For this application, the responsibilities are divided as follows:
| Component | Responsibility |
|---|---|
| Cognito User Pool | Manages user identities and authentication |
| Cognito managed login | Provides the hosted sign-in experience |
| Cognito app client | Defines the browser application's OAuth configuration |
| Cognito domain | Provides the authorization and token endpoints |
| API Gateway JWT authorizer | Validates tokens on protected API requests |
| Lambda | Executes application logic after authorization |
This separation means the application does not need to implement password handling, build its own login interface, or write custom JWT signature-validation code in the frontend.
It also allows the API to validate tokens independently of how the user interface is implemented.
4. Why OAuth 2.0 Authorization Code Flow with PKCE?
A static JavaScript application is a public OAuth client.
A public client cannot reliably protect a client secret because any secret embedded in its JavaScript bundle or browser configuration can be extracted.
For this reason, the browser application must not depend on a confidential client secret.
Instead, I used OAuth 2.0 Authorization Code Flow with Proof Key for Code Exchange (PKCE).
PKCE adds a cryptographic binding between the initial authorization request and the subsequent authorization-code exchange. It helps prevent an intercepted authorization code from being redeemed by an attacker who does not possess the corresponding code verifier.
Understanding PKCE
PKCE uses two important values:
-
code_verifier: A high-entropy random string generated by the client. -
code_challenge: A transformed version of that verifier, usually produced using SHA-256.
The browser sends the challenge when it initiates authorization. Later, when exchanging the authorization code for tokens, the client sends the original verifier.
Conceptually:
Browser Application | | Generate random code_verifier | | SHA-256(code_verifier) v code_challenge | v Cognito Authorization Endpoint | | User signs in v Authorization Code | v Browser Application | | Exchange code + code_verifier v Cognito Token Endpoint | | Verify PKCE binding v Access Token + Other Tokens The token endpoint checks that the verifier corresponds to the challenge supplied in the original authorization request.
An intercepted authorization code alone is therefore insufficient to complete the intended exchange.
PKCE does not eliminate every browser security risk. Cross-site scripting, compromised dependencies, and malicious scripts can still threaten tokens accessible to the application. PKCE protects the authorization-code exchange; it is not a substitute for secure frontend development.
Why not use the implicit flow?
The implicit flow returns tokens directly through the authorization response instead of exchanging an authorization code.
For a modern browser-based application, Authorization Code Flow with PKCE is the preferred approach. It supports the authorization-code exchange without requiring the static application to possess a client secret.
5. Configuring the Cognito app client
The Cognito app client defines how the frontend is allowed to authenticate.
For the Daara Media implementation, the important settings included:
- A Cognito User Pool.
- A public app client without a client secret.
- Authorization Code grant type.
- The
openid,email, andprofilescopes. - A Cognito managed-login domain.
- An explicitly configured callback URL.
- An explicitly configured sign-out URL.
The local development callback was:
http://localhost:8080/ 
The production deployment needs its own registered callback and sign-out URLs that match the deployed frontend's configuration.
Why callback URLs matter
After a successful login, Cognito redirects the browser to a registered callback URL with the authorization response.
For Authorization Code Flow, the response contains an authorization code and state value. The frontend then completes the code exchange.
Cognito validates the callback URL against the URLs registered for the app client. The application must also validate the returned state against the value it generated before redirecting to the authorization endpoint.
A simplified configuration looks like this:
Cognito User Pool | v App Client | +-- Public client | +-- Authorization Code grant | +-- PKCE | +-- openid, email, profile | +-- Callback URL | +-- Sign-out URL | v Managed Login Domain A mismatch between the configured callback and the URL used by the application can prevent the login flow from completing.
This is why localhost and production URLs must be configured deliberately rather than assumed to be interchangeable.
6. What happens during the complete OAuth flow?
The authentication sequence can be broken down into several stages.
Step 1: The user opens the application
The browser loads the static frontend.
The application checks whether it has a valid authentication state. If the user is not authenticated, it starts the sign-in process.
The frontend should not treat the mere presence of a token string as proof that the API will accept it. The API's authorizer remains responsible for validating tokens on protected requests.
Step 2: The browser generates PKCE values
The frontend generates a cryptographically random code_verifier, derives the code_challenge, and creates a random state value.
The state value helps the client correlate the callback with the authorization request it initiated and defend against login CSRF attacks.
Step 3: The browser redirects to Cognito
The browser is redirected to the Cognito authorization endpoint with parameters conceptually similar to these:
response_type=code client_id=YOUR_APP_CLIENT_ID redirect_uri=http://localhost:8080/ scope=openid+email+profile code_challenge=GENERATED_CHALLENGE code_challenge_method=S256 state=GENERATED_STATE This is an illustrative set of parameters, not a complete production authorization URL. A real implementation must correctly encode values and include the required Cognito domain and other applicable parameters.
Step 4: Cognito authenticates the user
Cognito presents its managed login experience.
After the user successfully authenticates, Cognito redirects the browser to the registered callback URL with an authorization code and the state value.
The frontend validates the returned state before proceeding.
Step 5: The application exchanges the authorization code
The frontend sends the authorization code, redirect URI, app client ID, and PKCE verifier to the Cognito token endpoint.
Because this is a public client, no client secret is included.
Cognito validates the request and returns the available tokens.
Step 6: The frontend calls the protected API
The frontend sends the access token in the HTTP Authorization header:
Authorization: Bearer ACCESS_TOKEN The token is sent over HTTPS.
The frontend must use the access token for API authorization, not assume that an ID token is interchangeable with an access token.
An ID token communicates information about the authenticated user to the client. An access token is intended to authorize access to protected resources.
Step 7: API Gateway validates the token
The request reaches API Gateway, where the JWT authorizer checks the token against its configured requirements.
If validation fails, the request is rejected before the integration invokes the chat Lambda.
If validation succeeds, API Gateway can invoke the configured backend integration.
7. API Gateway JWT authorization
This was one of the most important architectural decisions in the project.
I did not want the Lambda function to be the first component responsible for deciding whether a request was authenticated.
Instead, I placed JWT authorization at the API boundary.
For an API Gateway HTTP API, the JWT authorizer can validate the token's signature using the issuer's public signing keys and check relevant claims, including the issuer, audience or client ID, expiration, and other configured requirements.
If route scopes are configured, the authorizer can also enforce the required OAuth scopes.
The relevant configuration includes:
- Issuer URL: The Cognito User Pool issuer.
- Audience: The expected app client ID when using the Cognito access-token configuration described here.
- Identity source: The bearer token in the
Authorizationheader. - Route authorization: The JWT authorizer attached to the protected route.
- Scopes, where needed: The required OAuth scopes for the route.
For this project, the issuer followed this format:
https://cognito-idp.us-east-1.amazonaws.com/USER_POOL_ID The issuer must match the configured User Pool. The API's authorizer configuration must also use the expected app client ID and token format.
The request authorization decision
Incoming HTTP Request | v Extract Bearer Token | v JWT Authorizer | +--------+--------+ | | v v Validate token Validate token fails passes | | v v Reject request Check route scopes | +--------+--------+ | | v v Not allowed Allowed | | v v Reject Invoke Lambda This protects the API even when someone bypasses the frontend entirely.
The distinction is important: a static website can be copied or inspected, but a caller cannot make an invalid or expired token pass the API's configured JWT validation merely by modifying the frontend.
What the authorizer does not do
A valid JWT is not automatically proof that a user should access every resource or perform every operation.
JWT validation establishes that the token meets the authorizer's configured requirements. Application-level authorization may still be needed for staff roles, tenant boundaries, sensitive operations, or document-level permissions.
Similarly, a JWT authorizer does not validate the AI-generated content, protect against prompt injection by itself, or replace Bedrock Guardrails.
Each of these controls addresses a different threat.
8. How the authenticated request reaches the AI backend
Once API Gateway authorizes the request, the chat Lambda handles the application workflow.
For Daara Media, that workflow involves retrieving relevant company knowledge and generating a draft for human review.
Staff Browser | | POST /chat | Authorization: Bearer <access_token> v API Gateway HTTP API | v JWT Authorizer | | Authorized request v Chat Orchestrator Lambda | +--------------------------+ | | v v Bedrock Knowledge Base Claude Sonnet 4.5 | ^ | Retrieved context | +--------------------------+ | v Draft Response | v Human Review The Lambda function orchestrates the application logic. It is not responsible for replacing Cognito's authentication system or reimplementing API Gateway's JWT validation.
The frontend sends a request body containing the prompt expected by the backend. In this implementation, the relevant field was prompt.
For example:
{ "prompt": "Draft a social media caption using our brand guidelines." } This is an application contract, separate from the OAuth token.
The access token answers the authorization question. The request body contains the user's task.
Keeping these concerns separate makes the architecture easier to debug and maintain.
9. Debugging the integration: authentication is not the same as application success
One lesson from implementing this flow was that successful login does not guarantee a successful AI request.
The integration crosses multiple boundaries, and each can fail independently.
Issue 1: The login flow does not complete
Potential causes include:
- The callback URL does not exactly match the registered URL.
- The app client does not have the expected OAuth flow enabled.
- The requested scopes are not configured.
- The managed-login domain is not active or correctly configured.
- The application mishandles the returned state or authorization code.
The right approach is to inspect the authorization request, Cognito configuration, redirect URL, and browser console rather than assuming the password or User Pool is the problem.
Issue 2: The user is signed in, but the API rejects the request
A successful Cognito login establishes an authenticated session, but the API still needs a valid access token.
Check:
- Whether the frontend sends an access token rather than an ID token.
- Whether the
Authorizationheader uses theBearerscheme. - Whether the token has expired.
- Whether the configured issuer matches the Cognito User Pool.
- Whether the audience or client ID matches the expected token.
- Whether any required route scopes are present.
This is why testing the login screen alone is insufficient. The token must also work at the protected API boundary.
Issue 3: API Gateway returns an error after authentication succeeds
During my integration, the frontend initially sent message, while the Lambda expected prompt.
The request was rejected with a 400 Bad Request. Correcting the request body to use prompt resolved that mismatch.
The lesson is simple: a successful authentication flow does not guarantee that the application request is valid.
HTTP status codes must be interpreted in the context of the layer that generated them. A token-validation failure, a malformed request body, a Lambda exception, and an upstream Bedrock error require different debugging approaches.
Issue 4: The backend succeeds, but the frontend displays nothing
The backend response contract used a draft field and a sources field.
The frontend initially failed to read the draft property correctly, so the request could succeed without the generated response appearing in the interface.
The fix was to make the frontend consume the actual response contract.
This reinforced the need to test the full request lifecycle:
Login | v Access Token | v API Authorization | v Request Validation | v Lambda Execution | v Bedrock Processing | v Response Parsing | v UI Rendering A system can be secure at the authentication layer and still be broken at the application layer. Both need independent validation.
10. Security considerations for a static JavaScript frontend
A browser application has different security constraints from a server-side application.
Several considerations follow directly from that difference.
Never embed a client secret
A static JavaScript application cannot protect a client secret. Use a public Cognito app client and Authorization Code Flow with PKCE.
Use HTTPS in production
HTTPS protects tokens and request data while they travel between the browser and the service endpoints.
Local development may use a registered localhost callback, but production callback URLs should use the deployed HTTPS origin.
Handle tokens carefully
Avoid writing access tokens, ID tokens, authorization codes, or PKCE verifiers to application logs.
Choose token storage and session-management strategies carefully. Persistent browser storage can increase exposure if malicious JavaScript executes in the application origin. Use short-lived access tokens where appropriate, manage refresh tokens deliberately, and protect the frontend against cross-site scripting.
PKCE protects the authorization-code exchange, but it does not make a compromised browser safe.
Restrict API access independently of the UI
Do not rely on hiding UI controls or checking local JavaScript state. Attach the JWT authorizer to the API routes that require authentication.
Configure least privilege
Authentication should be combined with least-privilege IAM policies for Lambda execution roles and the AWS resources they access.
A valid staff token should not give the browser direct permission to invoke Bedrock or read private company documents from S3.
Separate authentication, authorization, and AI safety
These are related but distinct controls:
- Cognito authenticates users.
- API Gateway validates tokens and enforces configured route authorization.
- IAM controls AWS resource permissions.
- Bedrock Guardrails provide configured AI safety controls.
- Application logic enforces business rules and any additional access policies.
- Human review remains part of the content workflow.
No single control replaces the others.
11. What I learned from the architecture
The most important lesson was that authentication should not be implemented as a visual feature. It should be part of the application's request architecture.
Cognito allows the application to delegate user authentication to a managed identity service. PKCE adapts the OAuth authorization-code flow to a browser-based public client. API Gateway JWT authorization establishes a separate enforcement point before backend execution.
This produces a clear responsibility boundary:
| Layer | Security responsibility |
|---|---|
| Static frontend | Initiates login and attaches the access token |
| Cognito | Authenticates the user and issues tokens |
| OAuth 2.0 + PKCE | Protects the browser-based authorization-code exchange |
| API Gateway | Validates tokens and enforces configured route requirements |
| Lambda | Applies application logic and orchestrates the AI workflow |
| IAM | Limits AWS resource permissions |
| Bedrock Guardrails | Applies configured model-input and output safety controls |
This separation also improves debugging. When something fails, I can investigate the relevant layer instead of treating every error as an authentication problem.
Conclusion
Securing a static JavaScript AI application requires more than a login page.
For the Daara Media AI Creative Content Assistant, I used Amazon Cognito for authentication, OAuth 2.0 Authorization Code Flow with PKCE for the browser-based authorization flow, and API Gateway JWT authorization to protect the backend endpoint.
Once a request passes that boundary, Lambda orchestrates the retrieval and generation workflow using the company's knowledge base and Claude Sonnet 4.5.
The result is a cleaner separation of responsibilities: the browser handles the user experience, Cognito handles identity, API Gateway enforces token validation, and Lambda executes the application workflow.
The broader lesson is that a secure AI application is not just an AI model behind an API. It is a system of independently enforced security and application boundaries, each with a clearly defined responsibility.