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
# 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
- Thoroughly apply the 3-layer defense - permission checks at all of the authentication layer, the authorization layer, and the data layer
- Treat inter-company data isolation as absolute - completely prevent leakage of other companies' data with RLS and forced assignment of company_id
- 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.