# Complex Authentication / Authorization Design Realized with AWS Cognito + Terraform: An Enterprise-SaaS Practice Managing 8 Kinds of User Attributes

> Explains how to realize the complex per-user-attribute authentication / authorization essential to B2B SaaS with AWS Cognito. Publishes practical design patterns: 8 kinds of user attributes, page-level / API-level access control, pre-signed URLs, and full automation with Terraform IaC.

- Published: 2025-01-07
- Author: 友田 陽大
- Tags: AWS Cognito, Terraform, 認証設計, B2B SaaS, セキュリティ, IaC, アクセス制御
- URL: https://tomodahinata.com/en/blog/aws-cognito-complex-authentication-design
- Category: Authentication & authorization
- Pillar guide: https://tomodahinata.com/en/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase

## Key points

- B2B SaaS authentication / authorization is designed with a 3-layer defense of authentication layer, authorization layer, and data layer, not letting anything reach the data unless it breaks through all of them
- Hold the 8 kinds of industry attributes in Cognito custom attributes, and make company_id immutable after creation (mutable=false) to ensure data isolation
- Authorize with permission checks at the page level / API level, and forcibly assign the company ID server-side to writes like orders to prevent tampering
- At the data layer, thoroughly do row-level isolation by company_id with PostgreSQL RLS, structurally sealing off leakage of other companies' data
- Protect S3 files with pre-signed URLs (expiry + ownership confirmation), and keep the bucket private

---

# Complex Authentication / Authorization Design Realized with AWS Cognito + Terraform: An Enterprise-SaaS Practice Managing 8 Kinds of User Attributes

## Introduction: why is B2B SaaS authentication / authorization complex

Authentication for a consumer-facing app is basically a binary of "logged in" or "not logged in." But in **B2B SaaS (enterprise SaaS)**, complex requirements like the following arise.

- **Multiple user attributes**: not just "admin" and "general user," but different permissions per "department," "title," and "industry"
- **Inter-company data isolation**: never let a competitor's data be viewed (data-leakage risk)
- **Page-level / API-level access control**: "this screen is for the sales department only," "this API is for admins only"
- **Dynamic permission changes**: change permissions immediately in response to a user's title change

In this article, I publish the **authentication / authorization design managing 8 kinds of user attributes** that I implemented in a **Minister of Economy, Trade and Industry Award-winning product** (a B2B SaaS for lumber-distribution-industry DX), together with implementation examples using AWS Cognito + Terraform.

---

## Architecture overview: the 3-layer defense model

B2B SaaS authentication / authorization is designed with a **3-layer defense.**

```
Layer 1: Authentication
  ↓ AWS Cognito User Pools
  ↓ JWT token issuance

Layer 2: Authorization
  ↓ permission check by custom attributes
  ↓ page-level / API-level access control

Layer 3: Data Access Control
  ↓ Row-Level Security (RLS) by company ID
  ↓ Pre-signed URL
```

You can't access the data unless you break through all 3 of these layers.

---

## Layer 1: the authentication layer - designing AWS Cognito User Pools

### Requirement: manage 8 kinds of user attributes

In the lumber-distribution industry, the following 8 kinds of user attributes exist.

| User attribute | Description | Main permissions |
| -------------- | ---------- | -------------------- |
| `lumber_mill` | Sawmill | Inventory management, order placement/receipt |
| `market` | Market | View all inventory, set prices |
| `manufacturer` | Manufacturer | Product registration, inventory management |
| `precut` | Precut | Processing requests, delivery management |
| `builder` | Construction firm | Ordering, quote requests |
| `wholesaler` | Wholesaler | Set wholesale prices, ordering |
| `forestry` | Forestry | Log registration, market shipment |
| `other` | Other | View only |

### Designing the Cognito User Pool with Terraform

```hcl
# AWS Cognito User Pool
resource "aws_cognito_user_pool" "main" {
  name = "lumber-saas-user-pool"

  # パスワードポリシー（エンタープライズ基準）
  password_policy {
    minimum_length    = 12
    require_lowercase = true
    require_uppercase = true
    require_numbers   = true
    require_symbols   = true
  }

  # MFA（多要素認証）必須化
  mfa_configuration = "OPTIONAL" # 本番環境ではON推奨

  # アカウントロックアウト設定（ブルートフォース攻撃対策）
  account_recovery_setting {
    recovery_mechanism {
      name     = "verified_email"
      priority = 1
    }
  }

  # カスタム属性：ユーザー属性（8種類）
  schema {
    name                = "user_type"
    attribute_data_type = "String"
    mutable             = true # 後で変更可能

    string_attribute_constraints {
      min_length = 1
      max_length = 50
    }
  }

  # カスタム属性：会社ID（データ分離用）
  schema {
    name                = "company_id"
    attribute_data_type = "String"
    mutable             = false # 作成後変更不可（セキュリティ）

    string_attribute_constraints {
      min_length = 1
      max_length = 100
    }
  }

  # カスタム属性：権限リスト（カンマ区切り）
  schema {
    name                = "permissions"
    attribute_data_type = "String"
    mutable             = true

    string_attribute_constraints {
      max_length = 2048
    }
  }

  # Eメール検証必須
  auto_verified_attributes = ["email"]

  # タグ
  tags = {
    Environment = var.environment
    Project     = "lumber-saas"
  }
}

# App Client（フロントエンド用）
resource "aws_cognito_user_pool_client" "app" {
  name         = "lumber-saas-app-client"
  user_pool_id = aws_cognito_user_pool.main.id

  # OAuth 2.0 フロー
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["code", "implicit"]
  allowed_oauth_scopes                 = ["email", "openid", "profile"]

  # トークン有効期限
  access_token_validity  = 1  # 1時間
  id_token_validity      = 1  # 1時間
  refresh_token_validity = 30 # 30日

  # コールバックURL（本番環境では実際のドメイン）
  callback_urls = [
    "https://lumber-saas.example.com/callback",
    "http://localhost:3000/callback" # 開発環境
  ]

  logout_urls = [
    "https://lumber-saas.example.com/logout",
    "http://localhost:3000/logout"
  ]

  # PKCE必須化（セキュリティ強化）
  prevent_user_existence_errors = "ENABLED"
}

# Cognito Identity Pool（AWS リソースアクセス用）
resource "aws_cognito_identity_pool" "main" {
  identity_pool_name               = "lumber_saas_identity_pool"
  allow_unauthenticated_identities = false

  cognito_identity_providers {
    client_id               = aws_cognito_user_pool_client.app.id
    provider_name           = aws_cognito_user_pool.main.endpoint
    server_side_token_check = true
  }
}

# IAM Role（認証済みユーザー用）
resource "aws_iam_role" "authenticated" {
  name = "cognito_authenticated_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = "cognito-identity.amazonaws.com"
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "cognito-identity.amazonaws.com:aud" = aws_cognito_identity_pool.main.id
        }
        "ForAnyValue:StringLike" = {
          "cognito-identity.amazonaws.com:amr" = "authenticated"
        }
      }
    }]
  })
}

# S3アクセスポリシー（会社IDごとのフォルダ分離）
resource "aws_iam_role_policy" "authenticated_s3_policy" {
  name = "authenticated_s3_policy"
  role = aws_iam_role.authenticated.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "s3:GetObject",
        "s3:PutObject"
      ]
      Resource = [
        # ユーザーは自社のフォルダのみアクセス可能
        "arn:aws:s3:::lumber-saas-bucket/$${cognito-identity.amazonaws.com:sub}/*"
      ]
    }]
  })
}
```

---

## Layer 2: the authorization layer - page-level / API-level access control

### Front end: authorization check in Next.js Middleware

```typescript
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";

// ページごとの必要権限定義
const PAGE_PERMISSIONS: Record<string, string[]> = {
  "/dashboard": [], // 全ユーザー
  "/inventory": ["view_inventory"],
  "/orders/create": ["create_order"],
  "/users/manage": ["manage_users"], // 管理者のみ
  "/settings/company": ["manage_company"],
};

export async function middleware(request: NextRequest) {
  const token = request.cookies.get("access_token")?.value;

  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  try {
    // JWTトークン検証（AWS Cognito公開鍵使用）
    const { payload } = await jwtVerify(
      token,
      // AWS Cognito公開鍵（JWKSから取得）
      await getPublicKey()
    );

    const userType = payload["custom:user_type"] as string;
    const permissions = (payload["custom:permissions"] as string).split(",");

    // ページ単位の権限チェック
    const requiredPermissions =
      PAGE_PERMISSIONS[request.nextUrl.pathname] || [];
    const hasPermission = requiredPermissions.every((perm) =>
      permissions.includes(perm)
    );

    if (!hasPermission) {
      return NextResponse.redirect(new URL("/403", request.url));
    }

    // 権限OK
    return NextResponse.next();
  } catch (error) {
    console.error("Token verification failed:", error);
    return NextResponse.redirect(new URL("/login", request.url));
  }
}

export const config = {
  matcher: [
    "/dashboard/:path*",
    "/inventory/:path*",
    "/orders/:path*",
    "/users/:path*",
    "/settings/:path*",
  ],
};
```

### Back end: API authorization with a Flask decorator

```python
from functools import wraps
from flask import request, jsonify
import jwt
import requests

# AWS Cognito JWKS URL
COGNITO_JWKS_URL = "https://cognito-idp.ap-northeast-1.amazonaws.com/{user_pool_id}/.well-known/jwks.json"

def get_public_key(token):
    """AWS CognitoのJWKSから公開鍵を取得"""
    jwks = requests.get(COGNITO_JWKS_URL).json()
    # JWTヘッダーから kid を取得し、対応する公開鍵を返す
    # （実装省略、jose ライブラリ等を使用）
    return public_key

def require_permissions(*required_permissions):
    """権限チェックデコレータ"""
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            token = request.headers.get('Authorization', '').replace('Bearer ', '')

            if not token:
                return jsonify({'error': 'No token provided'}), 401

            try:
                # トークン検証
                public_key = get_public_key(token)
                payload = jwt.decode(token, public_key, algorithms=['RS256'])

                user_type = payload.get('custom:user_type')
                permissions = payload.get('custom:permissions', '').split(',')
                company_id = payload.get('custom:company_id')

                # 権限チェック
                for perm in required_permissions:
                    if perm not in permissions:
                        return jsonify({'error': f'Permission denied: {perm}'}), 403

                # リクエストコンテキストにユーザー情報を保存
                request.user = {
                    'user_type': user_type,
                    'permissions': permissions,
                    'company_id': company_id,
                }

                return f(*args, **kwargs)
            except jwt.ExpiredSignatureError:
                return jsonify({'error': 'Token expired'}), 401
            except jwt.InvalidTokenError as e:
                return jsonify({'error': f'Invalid token: {str(e)}'}), 401

        return decorated_function
    return decorator

# API エンドポイント例
@app.route('/api/inventory', methods=['GET'])
@require_permissions('view_inventory')
def get_inventory():
    """在庫取得（view_inventory 権限必須）"""
    company_id = request.user['company_id']

    # 自社の在庫のみ取得（他社データは取得不可）
    inventory = Inventory.query.filter_by(company_id=company_id).all()

    return jsonify([inv.to_dict() for inv in inventory])

@app.route('/api/orders', methods=['POST'])
@require_permissions('create_order')
def create_order():
    """発注作成（create_order 権限必須）"""
    company_id = request.user['company_id']
    data = request.json

    # 発注データに会社IDを強制付与（改ざん防止）
    order = Order(
        company_id=company_id,  # 必ずリクエスト元の会社ID
        product_id=data['product_id'],
        quantity=data['quantity'],
    )
    db.session.add(order)
    db.session.commit()

    return jsonify(order.to_dict()), 201
```

---

## Layer 3: the data layer - Row-Level Security (RLS)

### Data isolation with PostgreSQL RLS

```sql
-- テーブル作成（在庫テーブル）
CREATE TABLE inventory (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    company_id UUID NOT NULL, -- 会社ID（必須）
    product_name VARCHAR(255) NOT NULL,
    stock INT NOT NULL,
    price DECIMAL(10, 2) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- RLS有効化
ALTER TABLE inventory ENABLE ROW LEVEL SECURITY;

-- ポリシー: ユーザーは自社のデータのみ閲覧可能
CREATE POLICY company_isolation_policy ON inventory
    FOR SELECT
    USING (company_id = current_setting('app.current_company_id')::UUID);

-- ポリシー: ユーザーは自社のデータのみ更新可能
CREATE POLICY company_isolation_update_policy ON inventory
    FOR UPDATE
    USING (company_id = current_setting('app.current_company_id')::UUID);

-- ポリシー: ユーザーは自社のデータのみ削除可能
CREATE POLICY company_isolation_delete_policy ON inventory
    FOR DELETE
    USING (company_id = current_setting('app.current_company_id')::UUID);
```

### Implementation in SQLAlchemy (Python)

```python
from sqlalchemy import event
from sqlalchemy.engine import Engine

@event.listens_for(Engine, "connect")
def set_company_id(dbapi_conn, connection_record):
    """接続ごとに company_id を設定"""
    # Flaskリクエストコンテキストから company_id を取得
    if hasattr(request, 'user'):
        company_id = request.user['company_id']
        cursor = dbapi_conn.cursor()
        cursor.execute(f"SET app.current_company_id = '{company_id}'")
        cursor.close()

# クエリ実行（自動的に自社データのみ取得）
inventory = Inventory.query.all()  # company_id が自動フィルタされる
```

---

## Protecting images / PDFs with pre-signed URLs

### The challenge: preventing direct access to S3 objects

Files like images, PDFs, and Excel are stored in S3. But if you directly publish the S3 URL, **anyone who knows the URL can access it.**

### The solution: a pre-signed URL

```python
import boto3
from botocore.exceptions import ClientError

s3_client = boto3.client('s3', region_name='ap-northeast-1')

def generate_presigned_url(bucket_name, object_key, expiration=3600):
    """
    署名付きURL生成（有効期限付き）

    Args:
        bucket_name: S3バケット名
        object_key: S3オブジェクトキー
        expiration: 有効期限（秒）デフォルト1時間

    Returns:
        str: 署名付きURL
    """
    try:
        url = s3_client.generate_presigned_url(
            'get_object',
            Params={'Bucket': bucket_name, 'Key': object_key},
            ExpiresIn=expiration
        )
        return url
    except ClientError as e:
        print(f"Error generating presigned URL: {e}")
        return None

# API エンドポイント
@app.route('/api/invoices/<invoice_id>/pdf', methods=['GET'])
@require_permissions('view_invoice')
def get_invoice_pdf(invoice_id):
    """請求書PDF取得（署名付きURL）"""
    company_id = request.user['company_id']

    # 請求書の所有権確認
    invoice = Invoice.query.filter_by(id=invoice_id, company_id=company_id).first()
    if not invoice:
        return jsonify({'error': 'Invoice not found'}), 404

    # 署名付きURL生成（1時間有効）
    s3_key = f"invoices/{company_id}/{invoice_id}.pdf"
    presigned_url = generate_presigned_url('lumber-saas-bucket', s3_key, expiration=3600)

    return jsonify({'url': presigned_url})
```

**Security benefits:**

- **With an expiry**: the URL is automatically invalidated after 1 hour
- **Ownership confirmation**: only your own company's invoices are accessible
- **The S3 bucket is private**: no direct access

---

## Summary: the 3 principles of enterprise-SaaS authentication / authorization design

1. **Thoroughly apply the 3-layer defense** - permission checks at all of the authentication layer, the authorization layer, and the data layer
2. **Treat inter-company data isolation as absolute** - completely prevent leakage of other companies' data with RLS and forced assignment of company_id
3. **Ensure reproducibility with IaC** - code the entire infrastructure with Terraform, preventing the security configuration from becoming person-dependent

B2B SaaS authentication / authorization is of a dimensionally different complexity from a consumer-facing app. But with the combination of **AWS Cognito + Terraform + RLS**, you can realize enterprise-level security.

---

## Your B2B SaaS is realizable too

If you're troubled by complex authentication / authorization design, AWS Cognito configuration, or Terraform IaC, I'll support you. From requirements definition through design and implementation, I can handle it one-stop.

I offer a **free technical consultation (30 minutes)**, so feel free to contact me.

[Get in touch here](/contact)
