DEV Community

Cover image for Implement Authentication And/Or Authorization For Applications And AWS Services | ๐Ÿ—๏ธ Build A Secure Notes API

Implement Authentication And/Or Authorization For Applications And AWS Services | ๐Ÿ—๏ธ Build A Secure Notes API

Exam Guide: Developer - Associate
๐Ÿ—๏ธ Domain 2: Security
๐Ÿ“˜ Task 1: Implement Authentication And/Or Authorisation For Applications And AWS
Services

Authentication (who are you?) and Authorisation (what can you do?) are central to the DVA-C02, and just Cloud Development in general.
You need to know Cognito, IAM Roles and Policies, STS, Lambda Authorizers, and how to secure microservice-to-microservice communication or communication within a Microservices Architectural Environment.


๐Ÿ“˜ Concepts

Authentication vs Authorisation

Term What It Answers AWS Services
Authentication Who are you? Cognito User Pools, IAM
Authorisation What can you do? IAM Policies, Cognito Identity Pools, Lambda Authorizers

Amazon Cognito

User Pools vs Identity Pools

Feature User Pool Identity Pool
Purpose Authentication (sign up, sign in) Authorisation (temporary AWS credentials)
Returns JWT tokens (ID, access, refresh) AWS credentials (access key, secret, session token)
Use When You need a user directory Users need to call AWS services directly
Federation Google, Facebook, SAML, OIDC Google, Facebook, SAML, User Pools

User Pools = authentication (get tokens).
Identity Pools = authorisation (get AWS credentials).
They're often used together but serve different purposes.

The Three Cognito Tokens

Token Contains Use For
ID Token User identity claims (name, email, groups) Your application to identify the user
Access Token Scopes and permissions API authorisation
Refresh Token Long-lived credential Getting new ID/access tokens without re-authentication

API Gateway Authorisation Options

Option What It Does When to Use
None No auth Public APIs
IAM SigV4 signing Internal/service-to-service calls
Cognito Authorizer Validates JWTs from User Pool Simple JWT validation
Lambda Authorizer (Token) Custom logic on the token Complex auth, external IDPs
Lambda Authorizer (Request) Custom logic on full request Auth based on headers, query strings, source IP

IAM Roles vs Users

Type Credentials Use Case
IAM User Long-lived (access key + secret) Humans, legacy CLI access
IAM Role Temporary (via STS AssumeRole) Applications, EC2, Lambda, cross-account

Roles are always preferred for applications. Never hardcode access keys.
Lambda uses an execution role, EC2 uses an instance profile, ECS uses a task role.
All of these give your code temporary credentials automatically.

STS (Security Token Service) Operations

Operation Use Case
AssumeRole Same or cross-account role assumption
AssumeRoleWithWebIdentity Exchange OAuth/OIDC token for AWS credentials
AssumeRoleWithSAML Exchange SAML assertion for AWS credentials
GetSessionToken Temporary credentials (for MFA-protected API calls)

Credential Resolution Order (SDK Default Chain)

The AWS SDK looks for credentials in this order:

1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
2. Shared credentials file (~/.aws/credentials)
3. AWS config file (~/.aws/config)
4. Container credentials (ECS task role)
5. Instance profile (EC2 role)
6. SSO credentials (IAM Identity Center)


๐Ÿ—๏ธ Build A Secure Notes API

Now lets put these concepts into practice, by building a Secure Notes API with Cognito authentication:

  • A Cognito User Pool with Managed Login for sign-up/sign-in
  • An App Client configured with the authorization code grant flow
  • A Lambda function that stores and retrieves notes
  • An API Gateway REST API protected by a Cognito authorizer
  • Test users signing in and calling the API with JWT tokens

Prerequisites


Part I

Create the Cognito User Pool

The Cognito console now uses a streamlined, application-focused setup wizard with Managed Login (the successor to the classic Hosted UI). The wizard creates your user pool and app client in one flow.

Step 01: Open the Cognito console

Step 02: Click Create user pool

Step 03: Set up resources for your application
Application type: Traditional web application
Name your application: My Notes App
Options for sign-in identifiers: Email
Self-registration: โœ”
Required attributes for sign-up: email name
Click Create user directory

โœ…Green banner: Your application "My Notes App" and user pool "User pool - gcvhqy" have been created successfully! Follow the instruction to continue the setup.

๐Ÿ’กThe new console creates the user pool, app client, and managed login domain all in one step. "Traditional web application" configures the authorization code grant flow with a client secret by default. This is the recommended pattern for server-side apps.

Step 04: Get Your Pool Details
After creation, click on the user pool and note:

  • User pool ID (looks like us-east-1_ABC123XYZ)
  • App client ID (from the โ–ผ Applications tab โ†’ App clients)
  • Cognito domain (from the โ–ผ Branding tab โ†’ Domain )

Part II

Create a Test User

Step 01: In the User Pool, click the โ–ผ Users management tab
Click Users

Step 02: Users
Click Create user
User Information

  • Invitation message: Send an email invitation
  • Email address: Use a real email you can access
  • Temporary password: Generate a password

Click Create user

โœ…Green banner: User username@example.com has been created successfully.

You'll receive an email with a temporary password.

โš ๏ธ Don't log in yet. We'll do that through the Managed Login in a moment.


Part III

Test with Managed Login (Authorization Code Flow)

Step 01: View the hosted UI URL
From the โ–ผ Applications tab โ†’ App clients
Click View login page

https://YOUR_COGNITO_DOMAIN.auth.us-east-1.amazoncognito.com/login?
  client_id=YOUR_APP_CLIENT_ID&
  response_type=code&
  scope=email+openid+profile&
  redirect_uri=YOUR_CALLBACK_URL
Enter fullscreen mode Exit fullscreen mode

Step 02: Sign in
Sign in with the user credentials from the email
Cognito will force you to set a permanent password

Step 03: Change password

โœ…: Success page

Successfully signed in
This is the default redirect page for Amazon Cognito user pools.
You're seeing this page because your Amazon Cognito app client doesn't have a return URL set.

Step 04: After login, you'll be redirected to https://d84l1y8p4kdic.cloudfront.net/?code=2f927666-cd13-4e94-9af9-58887a9d9cd3...

๐Ÿ’กCopy the code value from the URL fragment.
You'll need it to call the API.

โš ๏ธ This code is NOT a token. It's a one-time authorization code that expires in a few minutes. You need to exchange it for actual JWT tokens in the next step.

Step 05: Exchange the Code for Tokens
Use the Cognito token endpoint to exchange the authorization code for JWT tokens:

curl -X POST "https://YOUR_COGNITO_DOMAIN.auth.us-east-1.amazoncognito.com/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code&code=YOUR_AUTH_CODE&client_id=YOUR_APP_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&redirect_uri=YOUR_CALLBACK_URL"
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ If your app client has a client secret (default for "Traditional web application"), you also need to include a client_secret parameter, or pass the credentials as a Basic auth header: -H "Authorization: Basic BASE64(client_id:client_secret)"

Expected response:

{
  "id_token": "eyJraWQiOiJ...",
  "access_token": "eyJraWQiOiJ...",
  "refresh_token": "eyJjdHkiOiJ...",
  "expires_in": 3600,
  "token_type": "Bearer"
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก Copy the id_token value. You'll need it to call the API.

response_type=token uses the implicit grant flow (returns tokens directly). response_type=code uses the authorization code flow (returns a code you exchange for tokens via the token endpoint).
Auth code is more secure for production apps.


Part IV

Create the DynamoDB Table

Step 01: Open the DynamoDB console โ†’ Create table

Step 02: Create table

  • Table name: Notes
  • Partition key: userId (String)
  • Sort key: noteId (String)
  • Table settings: Default settings

Click Create table

โœ…Green banner: The Notes table was created successfully.


Part V

Create the Notes Lambda Function

Step 01: Open Lambda โ†’ Create function

Step 02: Create function

  • Function name: NotesAPI
  • Runtime: Python 3.12

Click Create function

โœ…Green banner: Successfully created the function "NotesAPI"

Step 03: Configuration โ†’ General configuration โ†’ click Edit

  • Memory: 256 MB
  • Timeout: 0 min 10 sec

Click Save

โœ…Green banner: Successfully updated the function "NotesAPI".

Step 04: Add DynamoDB Permissions
Configuration โ†’ Permissions โ†’ click the Role name

Step 05: Add permissions โ–ผ โ†’ Attach policies โ†’ search AmazonDynamoDBFullAccess โ†’ attach

โœ…Green banner: Policies have been successfully attached to role.

Step 06: Function Code

import json
import boto3
import uuid
from datetime import datetime
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('Notes')

def lambda_handler(event, context):
    """
    Notes API with user isolation.
    Each user can only access their own notes โ€” enforced by extracting
    the userId from the JWT claims and using it as the partition key.
    """
    http_method = event.get('httpMethod', 'GET')
    path = event.get('path', '/')

    # Extract the authenticated user from Cognito claims
    # API Gateway puts the JWT claims here after the authorizer validates the token
    try:
        claims = event['requestContext']['authorizer']['claims']
        user_id = claims['sub']  # Cognito's unique user ID
        user_email = claims.get('email', 'unknown')
    except KeyError:
        return response(401, {'error': 'Unauthorized'})

    print(f"Request from user {user_email} (ID: {user_id})")

    try:
        if path == '/notes' and http_method == 'GET':
            return list_notes(user_id)

        elif path == '/notes' and http_method == 'POST':
            body = json.loads(event.get('body') or '{}')
            return create_note(user_id, body)

        elif path.startswith('/notes/') and http_method == 'GET':
            note_id = path.split('/')[-1]
            return get_note(user_id, note_id)

        elif path.startswith('/notes/') and http_method == 'DELETE':
            note_id = path.split('/')[-1]
            return delete_note(user_id, note_id)

        else:
            return response(404, {'error': 'Not found'})

    except Exception as e:
        print(f"Error: {str(e)}")
        return response(500, {'error': 'Internal server error'})


def list_notes(user_id):
    """List all notes for the authenticated user."""
    result = table.query(
        KeyConditionExpression=Key('userId').eq(user_id)
    )
    return response(200, {
        'notes': result['Items'],
        'count': result['Count']
    })


def create_note(user_id, body):
    """Create a new note owned by the authenticated user."""
    note_id = str(uuid.uuid4())
    note = {
        'userId': user_id,           # โ† partition key ensures data isolation
        'noteId': note_id,
        'title': body.get('title', 'Untitled'),
        'content': body.get('content', ''),
        'createdAt': datetime.utcnow().isoformat()
    }
    table.put_item(Item=note)
    return response(201, note)


def get_note(user_id, note_id):
    """Get a specific note. User can only access their own notes."""
    result = table.get_item(Key={'userId': user_id, 'noteId': note_id})
    item = result.get('Item')
    if not item:
        return response(404, {'error': 'Note not found'})
    return response(200, item)


def delete_note(user_id, note_id):
    """Delete a note. User can only delete their own notes."""
    table.delete_item(Key={'userId': user_id, 'noteId': note_id})
    return response(204, {})


def response(status_code, body):
    return {
        'statusCode': status_code,
        'headers': {'Content-Type': 'application/json'},
        'body': json.dumps(body, default=str)
    }
Enter fullscreen mode Exit fullscreen mode

Step 07: Click Deploy.

Notice the user isolation pattern. The userId comes from the JWT claims (set by Cognito, validated by API Gateway), not from the request body. Users can't forge this value because API Gateway won't invoke Lambda without a valid token. This is application-level authorization.


Part VI

Create the API Gateway API

Create the API

Step 01: Open API Gateway โ†’ Create API โ†’ REST API โ†’ Build

Step 02: Create REST API

  • New API
  • API name: NotesAPI
  • API endpoint type: Regional
  • Security policy - new: TLS_1_0

Click Create API

โœ…Green banner: Successfully created REST API 'NotesAPI (xoq5e4tu3j)'.

Create the Cognito Authorizer

Step 03: In the left sidebar, click Authorizers
Click Create authorizer

Step 04: Create authorizer

  • Authorizer name: CognitoAuth
  • Authorizer type: Cognito
  • Cognito user pool: Select NotesUserPool
  • Token source: Authorization (this is the header name)

Click Create authorizer

โœ…Green banner: Successfully created authorizer 'CognitoAuth'.

Create the Resources and Methods

Step 05: Click Resources โ†’ Create resource

Step 06: Create resource

  • Resource path: /
  • Resource name: notes
  • โœ” CORS (Cross Origin Resource Sharing)

Click Create resource

โœ…Green banner: Successfully created resource '/notes'

Step 07: Now create a child resource for /notes/{noteId}:
Select the /notes resource โ†’ Create resource

  • Resource path: /notes/
  • Resource name: {noteID} (proxy resource)

Click Create resource

โœ…Green banner: Successfully created resource '/notes/{noteID}'

Add the Methods

Step 08: For each method below, click the resource, then Create method:

Step 09: POST /notes (create a note)

  • โ–ผ Method type: POST
  • Integration type: Lambda Function
  • Lambda proxy integration: โœ”
  • Lambda function: NotesAPI

โœ…Green banner: Successfully created method 'POST' in 'notes'.

Step 10: GET /notes (list notes)

  • โ–ผ Method type: GET
  • Integration type: Lambda Function
  • Lambda proxy integration: โœ”
  • Lambda function: NotesAPI

โœ…Green banner: Successfully created method 'GET' in 'notes'.

Step 11: GET /notes/{noteId} (get one note)

  • โ–ผ Method type: GET
  • Integration type: Lambda Function
  • Lambda proxy integration: โœ”
  • Lambda function: NotesAPI

โœ…Green banner: Successfully created method 'GET' in 'noteID'.

Step 12: DELETE /notes/{noteId} (delete a note)

  • โ–ผ Method type: DELETE
  • Integration type: Lambda Function
  • Lambda proxy integration: โœ”
  • Lambda function: NotesAPI

โœ…Green banner: Successfully created method 'DELETE' in 'noteID'.

Attach the Authorizer to Each Method

Step 13: For each of the four methods:

13.1: Click the method (e.g., GET under /notes)
13.2: Click Method request โ†’ Edit
13.3: Set Authorization to CognitoAuth
13.4: Click Save

โœ…Green banners: Successfully edited method request for โ€˜xโ€™

Deploy the API

Step 14: Click Deploy API
Stage: Create a new stage called dev

Click Deploy

โœ…Green banner: Successfully created deployment for NotesAPI.

Step 15: Copy the Invoke URL


Part VII

Test the Authenticated API

Test One: Unauthenticated Request (Should Fail)

curl -X POST https://YOUR_API_URL/dev/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "My first note", "content": "Hello world"}'
Enter fullscreen mode Exit fullscreen mode

Expected response:

{"message": "Unauthorized"}
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ API Gateway rejected the request because there's no JWT token. Lambda was never invoked.

Test Two: Authenticated Request (Should Succeed)

Use the id_token you got from the token exchange in Part III:

curl -X POST https://YOUR_API_URL/dev/notes \
  -H "Authorization: eyJraWQiOiJ..." \
  -d '{"title": "My first note", "content": "Hello world"}'
Enter fullscreen mode Exit fullscreen mode

Expected response:

{
  "userId": "abc-123-def-456",
  "noteId": "7f8e9d0c-...",
  "title": "My first note",
  "content": "Hello world",
  "createdAt": "2026-04-24T10:00:00"
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก Notice the userId was pulled from the JWT claims. The user didn't have to send it.

Test Three: List Your Notes

curl https://YOUR_API_URL/dev/notes \
  -H "Authorization: YOUR_ID_TOKEN"
Enter fullscreen mode Exit fullscreen mode

You'll only see notes belonging to the authenticated user.

Test Four: Invalid Token (Should Fail)

curl https://YOUR_API_URL/dev/notes \
  -H "Authorization: invalid.token.here"
Enter fullscreen mode Exit fullscreen mode

Expected response:

{"message": "Unauthorized"}
Enter fullscreen mode Exit fullscreen mode

Part VIII

Build a Lambda Authorizer (Advanced)

Cognito authorizers are simple.
They just validate JWT signatures.
Lambda authorizers let you implement custom logic.
Let's see how.

Create the Authorizer Function

Step 01: Open Lambda โ†’ Create function

Step 02: Create function

  • Function name: NotesLambdaAuthorizer
  • Runtime: Python 3.12

Click Create function

โœ…Green banner: Successfully created the function "NotesLambdaAuthorizer".

Step 03: Paste this code

import json

def lambda_handler(event, context):
    """
    Custom Lambda authorizer.
    Returns an IAM policy that allows or denies the API call.

    Lambda authorizer results can be cached (default 5 min, max 1 hour)
    to reduce the number of Lambda invocations.
    """
    token = event.get('authorizationToken', '').replace('Bearer ', '')
    method_arn = event['methodArn']

    print(f"Authorizing request with token: {token[:20]}...")

    # In production, validate the JWT signature using the Cognito JWKS
    # For this demo, we just check if the token starts with a specific prefix
    if not token or token == 'invalid':
        # Deny access
        raise Exception('Unauthorized')

    # Simulate extracting user info from the token
    user_id = 'demo-user-001'
    user_role = 'admin' if 'admin' in token else 'user'

    # Build the policy based on the user's role
    if user_role == 'admin':
        effect = 'Allow'
        # Admin can access everything in the API
        resource = method_arn.rsplit('/', 2)[0] + '/*/*'
    else:
        effect = 'Allow'
        # Regular user can only access the specific method
        resource = method_arn

    return {
        'principalId': user_id,
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [{
                'Action': 'execute-api:Invoke',
                'Effect': effect,
                'Resource': resource
            }]
        },
        'context': {
            'userId': user_id,
            'userRole': user_role
        }
    }
Enter fullscreen mode Exit fullscreen mode

Step 04: Click Deploy

โœ…Green banner: Successfully updated the function "NotesLambdaAuthorizer"

Step 05: Attach It to the API (Walkthrough Only)
In API Gateway:
1. Authorizers โ†’ Create authorizer
2. Configure:

  • Name: LambdaAuth
  • Authorizer type: Lambda
  • Lambda function: NotesLambdaAuthorizer
  • Lambda event payload: Token
  • Token source: Authorization
  • Authorization caching: 300 seconds 3. Create authorizer

You could switch methods to use this instead of the Cognito authorizer. We won't switch for this tutorial. The Cognito authorizer is already doing its job.

๐Ÿ’กLambda authorizer results are cached (up to 1 hour). This reduces Lambda invocations but means permission changes aren't immediate. Two types:

  • Token-based: receives the token, caches by token value
  • Request-based: receives the full request, caches by specified identity sources.

๐Ÿ—๏ธ What You Built | ๐Ÿ“˜Exam Concepts Recap

What You Built Exam Concept
Created a Cognito User Pool Authentication via managed user directory
Configured the Cognito Hosted UI OAuth 2.0 authorization code / implicit grant flows
Signed in and received ID, access, and refresh tokens The three Cognito token types and their purposes
Attached a Cognito Authorizer to API Gateway methods JWT validation at the API layer
Extracted sub and email from JWT claims in Lambda Passing identity from API Gateway to Lambda
Used userId as the DynamoDB partition key Application-level authorization (data isolation)
Built a Lambda authorizer with custom logic Fine-grained authorization
Returned an IAM policy from the Lambda authorizer How Lambda authorizers control access
Set the Lambda authorizer cache TTL Performance vs permission freshness trade-off
Tested with and without a valid token API Gateway rejects unauthenticated requests before Lambda

โš ๏ธ Clean Up Protocol

  1. API Gateway โ†’ Delete NotesAPI
  2. Lambda โ†’ Delete NotesAPI, NotesLambdaAuthorizer
  3. DynamoDB โ†’ Delete the Notes table
  4. Cognito โ†’ Delete the User Pool (also delete the domain first)
  5. IAM โ†’ Delete the Lambda execution roles
  6. CloudWatch โ†’ Delete the log groups

Key Takeaways

  1. Cognito User Pools = authentication (tokens). Identity Pools = authorization (AWS credentials).
  2. Three tokens: ID (user identity), Access (API auth), Refresh (getting new tokens).
  3. Cognito Authorizer = simple JWT validation. Lambda Authorizer = custom logic.
  4. Lambda authorizer caching: (up to 1 hour) reduces invocations but delays permission changes.
  5. Never hardcode credentials: use IAM roles (execution role for Lambda, task role for ECS, instance profile for EC2).
  6. STS AssumeRole for cross-account access and temporary credentials.
  7. SigV4 signing is used for IAM authorization on API Gateway (service-to-service).
  8. Credential resolution order: env vars โ†’ credentials file โ†’ config โ†’ container โ†’ instance profile.
  9. JWT claims are passed to Lambda via event.requestContext.authorizer.claims.
  10. Application-level authorization: use the authenticated user ID as the partition key to enforce data isolation.

Additional Resources


๐Ÿ—๏ธ

Top comments (0)