Blog

How to build document access control with S3, WorkOS FGA, and Lambda authorizers

In this tutorial, paired with companion code, you’ll learn to build a secure, scalable document access control system using WorkOS FGA, AWS Lambda Authorizers, and Amazon S3.


In this tutorial, paired with companion code, you’ll learn to build a secure, scalable document access control system using WorkOS FGA, AWS Lambda Authorizers, and Amazon S3. We’ll guide you through:

  • Why this architecture is ideal for secure, scalable document management
  • How the WorkOS FGA schema enables seamless and flexible access control for diverse use cases
  • Step-by-step implementation, spotlighting the key building blocks
  • Testing and validating the entire setup with an automated demo script

The challenge and solution

The challenge

Alice, a software engineer on the Engineering team, creates a document in S3 that contains sensitive design specifications.

She needs to share it with her team but not with outsiders like Charlie, who isn't part of the team.

Bob, another team member, should be able to view the document but not edit it. Therefore our requirements are:

  •  👩 Alice can view her own document
  •  👨 Bob can view the team document
  •  🧑 Charlie cannot view Alice's document

How can we enforce these nuanced, relationship-based access controls in a cloud-native serverless architecture?

The solution

The system we’re building solves these challenges by combining:

  • Amazon S3 for secure, scalable document storage.
  • WorkOS FGA for relationship-based authorization, enabling inheritance and team-based permissions.
  • AWS Lambda Authorizers for enforcing these permissions dynamically, based on user tokens and access policies.

Alice’s document permissions can be automatically inherited by her team, ensuring Bob has seamless access while Charlie is denied:

  •  👨‍ ✅️ Bob can view the team document
  •  🙅‍♂️ Charlie cannot view Alice's document

The result is a secure, cloud-native solution that scales with your team’s needs.

How it works: overview and architecture

Let's examine the system architecture and its key components:

  • Amazon S3: Stores documents securely.
  • WorkOS FGA: Manages relationship-based permissions with a schema supporting users, teams, and roles like owner, editor, and viewer.
  • AWS Lambda Authorizer: Acts as a gatekeeper, validating JWT tokens and checking permissions with WorkOS FGA.

Workflow

  1. User Authentication: A user initiates a request to access a document by including their JWT token in the request headers. In a production environment, this token would likely be issued by your organization's authentication system, but for the purposes of this demo, we'll use a simple JWT token.
  2. Authorization Check:
    1. The Lambda authorizer validates the token and extracts the user ID
    2. The Lambda authorizer queries WorkOS FGA to check if the user can access the requested document
  3. Request Execution: If authorized, the request is forwarded to S3 to retrieve the document. Otherwise, access is denied.

This layered approach separates concerns, allowing S3 to handle storage and WorkOS FGA to manage access logic.

Following along with the code

To follow along, you'll need:

  • An AWS Account with appropriate permissions.
  • Node.js 18 or later installed.
  • AWS CLI configured locally.
  • A WorkOS account and API key.
  • AWS CDK CLI (npm install -g aws-cdk).

For detailed environment setup instructions, refer to the companion GitHub repository.

Designing the authorization schema

The core of our system is the WorkOS FGA authorization model. Our FGA schema defines three main types:

  • user: Individual users in the system.
  • team: Groups of users with shared access.
  • document: Documents with controlled access.

Here's our complete schema:

type user

type team
  relation member [user]

type document
  relation parent [team]
  relation owner [user]
  relation editor [user]
  relation viewer [user]

  inherit editor if
    relation owner

  inherit viewer if
    any_of
      relation editor
      relation member on parent [team]

What does this enable?

  • Team-based access: Documents can be shared with entire teams.
  • Document ownership: Users can be designated as document owners.
  • Role-based permissions: Support for owner, editor, and viewer roles.
  • Permission inheritance:
    • Document owners automatically get editor permissions.
    • Team members inherit viewer access to team documents.
    • Editors automatically get viewer permissions.

The WorkOS FGA authorization model is extremely flexible, and you can use it to build custom permissions systems for any scenario.

Why define our system with Infrastructure as Code (IaC)?

To manage and deploy the infrastructure for this project, we leveraged AWS CDK. CDK’s code-first approach lets us define AWS resources like the Lambda function, API Gateway, and S3 bucket directly in TypeScript.

This approach integrates seamlessly with application logic, making infrastructure easier to manage and understand.

Building and deploying the system: step by step

Let’s walk through the implementation step by step, starting with document storage and progressing through secure authentication, authorization, and testing.

1. Create the Amazon S3 Bucket

Set up secure, scalable document storage with Amazon S3. This bucket stores the documents while ensuring security via encryption and strict access policies: 


export class S3Stack extends cdk.Stack {
  public readonly documentsBucket: s3.Bucket;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    this.documentsBucket = new s3.Bucket(this, 'DocumentsBucket', {
      publicReadAccess: false,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      versioned: true,
      // The following settings are for demo purposes, to ensure easy teardowns
      // you may want to revisit these settings for a production environment
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // Provide the path to the test documents we want uploaded
    new s3deploy.BucketDeployment(this, 'DeployTestDocuments', {
      sources: [s3deploy.Source.asset('assets/sample-documents')],
      destinationBucket: this.documentsBucket,
    });
  }
} 

CDK enables us to provide a path to our sample documents and deploy them to the S3 bucket as part of the deployment process.

2. Create the Lambda authorizer function

Add a Lambda function, or authorizer, which our API Gateway will use to authorize requests before passing them to the S3 bucket.

We pass in the WorkOS API key and JWT secret as environment variables, which we can set in our .env.local file.


// Create Lambda function for the authorizer using NodejsFunction
    const authorizerFn = new nodejsfunction.NodejsFunction(this, 'AuthorizerFunction', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'handler',
      entry: path.join(__dirname, '../src/authorizer/index.ts'),
      description: `v1.0.0 built at ${new Date().toISOString()}`,
      environment: {
        WORKOS_API_KEY: process.env.WORKOS_API_KEY || '',
        JWT_SECRET: process.env.JWT_SECRET || 'your-secret-key',
      },
      bundling: {
        minify: true,
        sourceMap: true,
        format: nodejsfunction.OutputFormat.CJS,
        mainFields: ['main', 'module'],
        target: 'node18',
        externalModules: [
          'aws-sdk',
        ],
        forceDockerBundling: true,
        logLevel: nodejsfunction.LogLevel.INFO,
      },
    });

This CDK code automatically bundles the authorizer function and creates a Lambda function with the contents of index.ts, which means we don't have to manage the deployment in a separate file format such as JSON or YAML.

We can write TypeScript for both our application and infrastructure code and let CDK handle the rest.

The authorizer function validates the JWT token and checks the user's permissions with WorkOS FGA:


export const handler = async (event: APIGatewayTokenAuthorizerEvent): Promise => {
  try {
    console.log('🔑 Authorization event received');
    console.log('Event details:', JSON.stringify(event, null, 2));

    const token = event.authorizationToken;
    const documentId = event.methodArn.split(':').pop()?.split('/').pop();
    if (!documentId) {
      throw new Error('Could not extract document ID from request');
    }

    const userId = getUserIdFromToken(token);
    console.log(`🔍 Checking if 👤 User ID: ${userId} can access 📄 Document ID: ${documentId}`);

    const checkResponse = await workos.fga.check({
      checks: [{
        resource: { 
          resourceType: 'document',
          resourceId: documentId 
        },
        relation: 'viewer',
        subject: {
          resourceType: 'user',
          resourceId: userId
        }
      }]
    });

    console.log('✅ FGA check response:', checkResponse);

    return {
      principalId: userId,
      policyDocument: {
        Version: '2012-10-17',
        Statement: [
          {
            Action: 'execute-api:Invoke',
            Effect: checkResponse.isAuthorized() ? 'Allow' : 'Deny',
            Resource: event.methodArn,
          },
        ],
      },
    };
  } catch (error) {
    console.error('❌ Authorization error:', (error as Error).message);
    throw new Error('Unauthorized');
  }
}; 

Lambda authorizers work by executing whatever custom logic you want and then returning an IAM policy document that either allows or denies access to the requested resource.

WorkOS FGA directly drives the authorization logic, and Lambda returns the appropriate IAM policy document based on check results.

3. Secure JWT authentication

Add stateless authentication using JWTs. Each token encodes the user ID and is signed with a secret to ensure integrity.

Why JWTs?

  • Tokens are compact and easy to pass in request headers.
  • They support stateless validation, reducing server-side complexity.

Generating JWTs:


import * as jwt from 'jsonwebtoken';

const secret = process.env.JWT_SECRET || 'your-secret-key';

// Generate a token for a user
const token = jwt.sign({ sub: 'userId' }, secret);
console.log('Generated Token:', token);

Using JWTs in API Requests:

  • Tokens are included in the Authorization header:

Authorization: Bearer <JWT_TOKEN>

During Lambda execution, the authorizer:

  1. Validates the JWT signature and extracts the sub field.
  2. Maps sub to the user’s permissions in WorkOS FGA.

4. Integrate with API Gateway

Use API Gateway to route requests to S3 with Lambda-based authorization.

This code sets up an API Gateway endpoint to retrieve objects from an S3 bucket using a dynamic documentId path parameter:


// Create API Gateway
    const api = new apigateway.RestApi(this, 'WorkOSFgaApi', {
      restApiName: 'WorkOS FGA S3 API',
      description: 'API Gateway with WorkOS FGA authorization for S3 access',
    });

    // Create IAM role for API Gateway to access S3
    const apiGatewayRole = new iam.Role(this, 'ApiGatewayS3Role', {
      assumedBy: new iam.ServicePrincipal('apigateway.amazonaws.com'),
    });

    props.bucket.grantRead(apiGatewayRole);
    props.bucket.grantWrite(apiGatewayRole);

    // Create API resources and methods
    const documents = api.root.addResource('documents');
    const document = documents.addResource('{documentId}');

    // GET method for retrieving documents
    document.addMethod('GET', new apigateway.AwsIntegration({
      service: 's3',
      integrationHttpMethod: 'GET',
      path: `${props.bucket.bucketName}/{documentId}`,
      options: {
        credentialsRole: apiGatewayRole,
        requestParameters: {
          'integration.request.path.documentId': 'method.request.path.documentId',
        },
        integrationResponses: [{
          statusCode: '200',
        }],
      },
    }), {
      authorizer: authorizer,
      requestParameters: {
        'method.request.path.documentId': true,
      },
      methodResponses: [{
        statusCode: '200',
      }],
    });
  }
 
 

This code sets up an IAM role, configures API Gateway, and integrates it with S3 to dynamically retrieve objects using a documentId path parameter.

API Gateway uses the apiGatewayRole to access S3 securely. The setup includes a documents resource and a nested documents/{documentId} resource, allowing dynamic S3 key resolution.

A GET method on /documents/{documentId} maps documentId to an S3 key, retrieves the object, and returns it with a 200 status on success.

The Lambda authorizer and WorkOS FGA enforce user-specific permissions, authorizing or denying requests. When a user calls GET /documents/{documentId}, the document is securely returned, or access is denied based on their permissions.

5. Deploy and test the system

Now that the components are in place, deploy the infrastructure and validate it with a demo script. CDK simplifies deployment by synthesizing these constructs into CloudFormation templates and managing the entire stack. To deploy the system, follow along with the instructions in the README:

  1.   Ensure prerequisites like the AWS CLI and CDK CLI are installed.
  2.   Clone the repository and set up environment variables.
  3.   Deploy the two stacks:

cdk deploy --all

6. Testing with JWTs

We’ve included a comprehensive demo script in the repository to ensure our system works as expected. It generates JWTs and tests authorization rules using API Gateway.

Here’s a simplified version of the flow:

1. Generate JWTs for test users:


const aliceToken = jwt.sign({ sub: 'alice' }, secret);
const bobToken = jwt.sign({ sub: 'bob' }, secret);
const charlieToken = jwt.sign({ sub: 'charlie' }, secret);

2. Make API requests:


const response = await axios.get(`${apiUrl}/documents/${documentId}`, {
  headers: { Authorization: `Bearer ${aliceToken}` },
});
console.log('Response:', response.data);

Running the demo script

Ensure your environment is properly configured with:

  • WORKOS_API_KEY: From your WorkOS Dashboard.
  • JWT_SECRET: Set this in your .env.local file. You can use a random string or openssl to generate one: (openssl rand -base64 32). Note that the demo script and deployed Lambda authorizer must use the exact same secret.

Run the script with:

npm run demo

Expected results:

The demo script performs comprehensive tests to validate the system's functionality:

  • Alice can access her document.
  • Bob can access team documents.
  • Charlie is denied access to Alice’s document.

Here's what you'll see when you run it:


> workos-fga-lambda-s3@1.0.0 demo
> ts-node scripts/demo.ts


📚 WorkOS FGA Demo: Document Access Control

Part 1: Testing FGA Rules Directly

🏗️  Setting up test environment...
├── Creating Engineering team
├── Adding test users:
│   ├── Alice (Engineering team member)
│   ├── Bob (Engineering team member)
│   └── Charlie (No team affiliations)
└── Creating test documents:
    ├── owner-only-doc.txt (owned by Alice)
    └── team-doc-1.txt (shared with Engineering team)
🧪 Testing FGA Authorization Rules:


🔍 Direct FGA Authorization Checks:

   👩 Alice can view her own document: ✅
   👨 Bob can view team document: ✅
   🧑 Charlie cannot view Alice's document: ✅

Part 2: Testing API Access

🏗️  Getting API Gateway URL...

🔑 Testing document access through API:


1️⃣  Owner Access
   Scenario: 👩 Alice accessing her personal document (owner-only-doc.txt)
   Expectation: Access should be granted (Alice is owner)

   🎟️  Creating JWT token for user: alice
   📝 Token payload: {
  "sub": "alice"
}

   🌐 Making API request:
   └── URL: https://4qw19xcy87.execute-api.us-east-1.amazonaws.com/prod//documents/owner-only-doc.txt
   └── Headers:
      ├── Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
      └── Warrant-Token: MTczMzMzODIzMjgxMjk1...

   ⏳ Awaiting response from Lambda authorizer...
   ✅ Authorization successful
   📨 Response details:
   └── Status: 200 OK
   └── Document content: "This is a sample document accessible only by its owner. "

   ─────────────────────────────────────


2️⃣  Team Access
   Scenario: 👨 Bob accessing team document (team-doc-1.txt)
   Expectation: Access should be granted (Bob is team member)

   🎟️  Creating JWT token for user: bob
   📝 Token payload: {
  "sub": "bob"
}

   🌐 Making API request:
   └── URL: https://4qw19xcy87.execute-api.us-east-1.amazonaws.com/prod//documents/team-doc-1.txt
   └── Headers:
      ├── Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
      └── Warrant-Token: MTczMzMzODIzMjgxMjk1...

   ⏳ Awaiting response from Lambda authorizer...
   ✅ Authorization successful
   📨 Response details:
   └── Status: 200 OK
   └── Document content: "This is a sample document accessible by team members. "

   ─────────────────────────────────────


3️⃣  Testing Unauthorized Access
   Scenario: 🧑 Charlie attempting to access protected document (owner-only-doc.txt)
   Expectation: 🧑 Charlie should be denied access as he is not authorized

   🎟️  Creating JWT token for user: charlie
   📝 Token payload: {
  "sub": "charlie"
}

   🌐 Making API request:
   └── URL: https://4qw19xcy87.execute-api.us-east-1.amazonaws.com/prod//documents/owner-only-doc.txt
   └── Headers:
      ├── Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
      └── Warrant-Token: MTczMzMzODIzMjgxMjk1...

   ⏳ Awaiting response from Lambda authorizer...
   ✅ Authorization correctly denied
   📨 Response details:
   └── Status: 403
   └── Error: {"Message":"User is not authorized to access this resource with an explicit deny"}

Thanks for reading

This architecture provides a robust foundation for document access control that:

  • Supports fine-grained permissions at both user and team levels.
  • Scales efficiently with your organization.
  • Minimizes operational overhead through serverless components.

Consider extending the system with:

For complete code and deployment instructions, visit our GitHub repository.

If you're looking to implement FGA in your own system, WorkOS FGA is a great place to start.

In this article

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.