Skip to main content
友田 陽大
Authentication & authorization
AWS Cognito
Terraform
認証設計
B2B SaaS
セキュリティ
IaC
アクセス制御

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
Reading time
9 min read
Author
友田 陽大
Share

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 attributeDescriptionMain permissions
lumber_millSawmillInventory management, order placement/receipt
marketMarketView all inventory, set prices
manufacturerManufacturerProduct registration, inventory management
precutPrecutProcessing requests, delivery management
builderConstruction firmOrdering, quote requests
wholesalerWholesalerSet wholesale prices, ordering
forestryForestryLog registration, market shipment
otherOtherView only

Designing the Cognito User Pool with Terraform

# 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

// 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

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

-- テーブル作成(在庫テーブル)
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)

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

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

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading