Building a multi-tenant SaaS platform from scratch means making hard architectural decisions early: who owns authentication, where do permissions live, how does the backend connect to the auth provider, and how do you ship confidently across two completely separate codebases — a backend API and a mobile/web app?

This post walks through every layer of a real production system: Supabase as the auth backbone, Django + DRF running serverlessly on AWS Lambda, AWS CDK for infrastructure-as-code, and a Flutter app for the client — all wired together with GitHub Actions pipelines that handle testing, deploying, building, and publishing.


The Architecture at a Glance

Production SaaS runtime architecture
Runtime architecture: Flutter → CloudFront → Lambda (Django) → Supabase

Repos:

  • django-project/ — Django + DRF, AWS CDK, GitHub Actions CI + deploy
  • flutter-app/ — Flutter (Android, iOS, web), GitHub Actions build + publish
ConcernChoiceWhy
AuthSupabase (ES256 JWT)Handles OAuth, magic links, email, password reset out of the box
PermissionsEmbedded in JWT app_metadataZero DB round-trips per request; consistent across client and server
ComputeAWS Lambda (Python 3.13)No servers to manage; scales to zero
IaCAWS CDK (Python)Full Python stack, typed constructs
Client stateFlutter BLoCPredictable auth state machine

Permission Model

In a multi-tenant SaaS, users typically operate within a tenant boundary and may have different levels of access to different resources within that tenant. Rather than hitting the database on every request to resolve what a user can do, a common pattern is to embed permissions directly in the JWT — so the backend has everything it needs the moment the token is verified.

There are many ways to represent permissions — string arrays, role enums, policy objects. In this blog we use bitmasks: a single integer where each bit position maps to an action you define. For example, you might say bit 0 means read, bit 1 means write, bit 2 means delete — so a value of 3 (binary 011) means read and write. Checking whether a user has a permission is then a single bitwise AND — the specific bit definitions are entirely up to you:

READ   = 1  # 001
WRITE  = 2  # 010
DELETE = 4  # 100

user_perms = 3  # 011 — has READ and WRITE

def has_permission(user_perms, required):
    return (user_perms & required) == required

has_permission(user_perms, READ)   # True
has_permission(user_perms, DELETE) # False

These bitmasks need to travel with every request. JWTs carry a payload of claims — standard fields like sub (user ID) and exp (expiry), plus any custom data you add. Supabase exposes app_metadata for server-controlled custom claims the client cannot tamper with — your permission bitmasks live there:

{
  "sub": "user-uuid",
  "exp": 1234567890,
  "app_metadata": {
    // your permission data goes here
  }
}

Throughout this blog we use organisations as the tenant boundary and locations as a resource within a tenant — giving us two permission layers: one org-wide bitmask and a per-location bitmask keyed by location UUID. Your data model will differ, but the pattern applies the same way.


Part 1: Supabase Integration

Why ES256 (JWKS), Not HS256

Supabase supports both HS256 (shared secret) and ES256 (asymmetric). ES256 is better for a backend you control: the backend only needs the public key — the private key never leaves Supabase. Key rotation is handled automatically via JWKS endpoint.

# utils/auth/supabase.py
from django.conf import settings
from jwt import PyJWKClient

# Example: https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json
JWKSClient = PyJWKClient(
    settings.SUPABASE_JWKS_URL,
    cache_keys=True,          # caches the JWKS in-process
)

cache_keys=Truereduces redundant JWKS fetches within a single warm instance — but Lambda instances don't share memory, so every cold start and concurrent invocation fetches independently. This is generally acceptable since JWKS keys rotate infrequently.

The Authentication Backend

Once the JWT is verified, app_metadata is unpacked onto the request user object — making identity and permissions available to every view without an extra database query. The exact fields you store in app_metadata depend on your data model; in this architecture we carry org identity, an org-level bitmask, and per-location bitmasks keyed by location UUID.

class SupabaseAuthentication(authentication.BaseAuthentication):
    def authenticate(self, request):
        auth_header = request.META.get("HTTP_AUTHORIZATION", "")
        if not auth_header.startswith("Bearer "):
            return None

        token = auth_header.split(" ")[1]

        try:
            signing_key = JWKSClient.get_signing_key_from_jwt(token)
            payload = jwt.decode(
                token,
                signing_key.key,
                algorithms=["ES256"],
                audience="authenticated",
            )

            user, _ = User.objects.get_or_create(
                id=payload.get("sub"),
                defaults={
                    "username": payload.get("email", ""),
                    "name": payload.get("user_metadata", {}).get("full_name", ""),
                }
            )

            # Permissions live in the JWT — no extra DB query
            app_metadata = payload.get("app_metadata", {})
            user.org_id = app_metadata.get("org_id")
            user.org_permissions = app_metadata.get("org_permissions", "0")
            user.location_permissions = app_metadata.get("location_permissions", {})
            user.app_metadata = app_metadata

        except jwt.InvalidTokenError:
            raise exceptions.AuthenticationFailed("Invalid token")

        return user, token

Two things to notice:

  1. get_or_create on the Django User model — Supabase is the source of truth for identity. Django's user table is a thin shadow that gives you a model to attach to. You never create users in Django directly.
  2. Permissions are dynamic attributes on the user object — they come from the JWT payload, not from Django's permission tables. This means they are always consistent with what Supabase has, updated on every new token.

Embedding Permissions in the JWT via app_metadata

Supabase JWTs include a custom app_metadata claim you control. This is how the backend injects role-based permission bitmasks that travel with every request at zero cost:

{
  "app_metadata": {
    "org_id": "uuid-of-org",
    "org_permissions": "127",
    "location_permissions": {
      "uuid-of-location-A": "63",
      "uuid-of-location-B": "3"
    }
  }
}

Syncing Permissions Back to Supabase

Whenever a role or membership changes in Django, the JWT must be refreshed so the client gets updated permissions. The sync function reads the current DB state and pushes it to Supabase via the Admin API:

class SupaUtils:
    @staticmethod
    def refresh_user_permissions(user_id: str):
        from app.models import OrgMember, LocationMember

        try:
            member = OrgMember.objects.select_related("role", "org").get(
                user_id=user_id, active=True
            )
            org_permissions = str(member.role.permissions) if member.role else "0"
            default_gp = str(member.role.default_location_permissions) if member.role else "0"

            location_permissions = {}
            for loc_member in LocationMember.objects.select_related("role", "location").filter(
                user_id=user_id, location__org=member.org, active=True
            ):
                location_permissions[str(loc_member.location.id)] = (
                    str(loc_member.role.permissions) if loc_member.role else default_gp
                )

            return SupaUtils.update_app_metadata_for_user(
                user_id=user_id,
                org_id=str(member.org.id),
                org_permissions=org_permissions,
                location_permissions=location_permissions,
                org_active=member.org.is_active,
            )

        except OrgMember.DoesNotExist:
            return SupaUtils.update_app_metadata_for_user(
                user_id=user_id,
                org_id="",
                org_permissions="0",
                location_permissions={},
            )

    @staticmethod
    def update_app_metadata_for_user(user_id, org_id, org_permissions, location_permissions, org_active=True):
        url = f"{settings.SUPABASE_URL}/auth/v1/admin/users/{user_id}"
        headers = {
            "Authorization": f"Bearer {settings.SUPABASE_SECRET_KEY}",
            "apikey": settings.SUPABASE_SECRET_KEY,
        }
        payload = {
            "app_metadata": {
                "org_id": org_id,
                "org_permissions": org_permissions,
                "location_permissions": location_permissions,
                "org_active": org_active,
            }
        }
        response = requests.put(url, json=payload, headers=headers, timeout=5)
        if response.status_code >= 400:
            raise RuntimeError(f"Supabase metadata update failed: {response.status_code}")

        # Revoke existing sessions — forces the client to get a fresh JWT
        SupaUtils.revoke_user_sessions(user_id)
        return response.json()

    @staticmethod
    def revoke_user_sessions(user_id: str):
        url = f"{settings.SUPABASE_URL}/auth/v1/admin/users/{user_id}/logout"
        headers = {
            "Authorization": f"Bearer {settings.SUPABASE_SECRET_KEY}",
            "apikey": settings.SUPABASE_SECRET_KEY,
        }
        requests.post(url, headers=headers, timeout=5)

Bitmask Permissions

Permissions are stored as integers in the database and as strings in the JWT. All checks go through a single utility to avoid bugs:

class PermissionManager:
    @staticmethod
    def has_permission(user_perm: str | int, required: int) -> bool:
        perm = int(user_perm) if isinstance(user_perm, str) else user_perm
        return (perm & required) == required

Configuring Supabase Auth Redirect URLs

One non-obvious production requirement: Supabase redirects (email confirmation, magic link, password reset) default to localhost unless you configure them per environment.

Dashboard → Authentication → URL Configuration:

FieldValue
Site URLhttps://app.yourdomain.com
Redirect URLsOne per line per environment
https://app.yourdomain.com/**
https://staging.yourdomain.com/**
yourapp://reset-password          # Flutter deep link scheme
http://localhost:3000/**          # local Flutter/web dev

Part 2: Backend CI/CD with GitHub Actions

The backend has five workflow files with a clean separation of concerns: CI, two thin environment-deploy triggers, the shared deploy that does the real work, and a utility workflow for running management commands on demand.

Workflow 1: CI (ci.yml)

Runs on every push and pull request to main or dev. Spins up a real PostgreSQL service — not SQLite, not mocks — so migration and query tests reflect production behaviour.

name: CI

on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main, dev]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test_db
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_pass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.13'
      - run: pip install -r requirements.txt
      - name: Run tests
        env:
          MODE: Test
        run: python manage.py test

You might wonder why not spin up a Supabase instance instead — but plain Postgres is enough here. CI only needs to verify your Django models, migrations, and queries behave correctly. Supabase-specific features like auth and RLS are not under test in this pipeline, so the extra complexity isn't worth it.

Workflow 2 & 3: Environment Deploys

deploy_dev.yml and deploy_prod.yml are thin wrappers. They exist purely to name the trigger and pass stage to the shared reusable workflow, keeping the actual deploy logic in one place:

# deploy_prod.yml
name: Deploy to Prod

on:
  workflow_dispatch:
    inputs:
      tag:
        description: 'Tag to deploy (e.g. v1.2.3)'
        required: true

jobs:
  deploy:
    uses: ./.github/workflows/deploy.yml
    with:
      stage: prod
      tag: ${{ inputs.tag }}
    secrets: inherit

Production deploys are always manual and tag-based — there is no auto-deploy on push to main. This gives you an explicit gate before anything hits production. This setup uses a personal GitHub account, so workflow_dispatch is the simplest way to enforce that gate. If you are on a GitHub organisation or a paid plan, you can go further — environment protection rules let you set up automated deployment triggers (e.g. push to main auto-deploys to staging) and require named approvers before the production job is allowed to run. That gives you a full promote-with-approval pipeline and a built-in audit trail, without changing the underlying workflow structure.

Workflow 4: The Shared Deploy (deploy.yml)

This is where the real work happens. It runs CDK to deploy the Lambda + infrastructure, then invokes the deployed Lambda to run migrations and collect static files.

name: Deploy

on:
  workflow_call:
    inputs:
      stage:
        required: true
        type: string
      tag:
        required: true
        type: string

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.stage }}    # GitHub environment for secrets

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.tag }}        # always deploy from a tagged commit

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-south-1

      - name: Bootstrap env artifact
        env:
          ARTIFACTS_BUCKET: ${{ vars.ARTIFACTS_BUCKET }}   # from the GitHub environment
        run: bash scripts/bootstrap_env.sh

      - name: CDK Deploy
        run: |
          source $GITHUB_WORKSPACE/.env-artifact/activate.sh
          stage="${{ inputs.stage }}"
          cdk deploy "myapp-${stage}" \
            --context env="${stage^}" \
            --context tag="${{ inputs.tag }}" \
            --require-approval never
        working-directory: cdk

      - name: Run migrations
        run: |
          aws lambda invoke \
            --function-name myapp-${{ inputs.stage }}-admin-tasks \
            --payload '{"command":"migrate","args":["--noinput"]}' \
            --cli-binary-format raw-in-base64-out \
            /tmp/migrate-response.json
          cat /tmp/migrate-response.json

      - name: Collect static files
        run: |
          aws lambda invoke \
            --function-name myapp-${{ inputs.stage }}-admin-tasks \
            --payload '{"command":"collectstatic","args":["--noinput"]}' \
            --cli-binary-format raw-in-base64-out \
            /tmp/collectstatic-response.json
          cat /tmp/collectstatic-response.json

Key design decisions:

Migrations via Lambda invocation, not a separate container. After CDK deploys the new Lambda code, you invoke the already-deployed function to run manage.py migrate. This is the same runtime environment as the production handler — same Python version, same layer, same environment variables. No "migration runner" container to maintain.

admin-tasks Lambda — a second Lambda function that handles long-running management commands (timeout: 5 minutes). The app Lambda has a 30-second timeout, which is too short for migrations on large tables. The admin-tasks function runs the same Django application code but with a different handler and timeout:

# handlers/admin.py
import io
from django.core.management import call_command

def handler(event, context):
    command = event.get("command")
    args = event.get("args", [])

    buf = io.StringIO()
    call_command(command, *args, stdout=buf, stderr=buf)
    return {"output": buf.getvalue()}

bootstrap_env.sh — the most overlooked but most important optimisation. CDK requires both Python and Node.js. Installing them on every run takes 3–4 minutes. The script hashes requirements.txt and package.json, looks for a matching tarball in S3, and either downloads it (fast) or builds and uploads it (slow, once):

DEPS_HASH=$(cat requirements.txt requirements-cdk.txt package.json | sha256sum | cut -c1-16)
ARTIFACT_KEY="myapp-${DEPS_HASH}.tar.gz"

if aws s3 ls "s3://${BUCKET}/artifacts/${ARTIFACT_KEY}" > /dev/null 2>&1; then
    aws s3 cp "s3://${BUCKET}/artifacts/${ARTIFACT_KEY}" /tmp/env.tar.gz
    tar xzf /tmp/env.tar.gz -C .env-artifact/
else
    # build venv + install CDK Node + npm install
    # ... tar and upload
fi

This turns a 4-minute dependency install into a 15-second S3 download on the common path.

Workflow 5: Management Commands on Demand

A fifth workflow lets you invoke any Django management command against any environment from the GitHub UI — useful for data backfills, cache warmups, and debugging:

name: Management Command

on:
  workflow_dispatch:
    inputs:
      environment:
        type: choice
        options: [dev, prod]
      command:
        description: 'e.g. migrate, collectstatic, send_reminders'
      args:
        description: 'JSON array e.g. ["--noinput"]'
        default: '[]'

jobs:
  run:
    environment: ${{ inputs.environment }}
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        ...
      - run: |
          PAYLOAD=$(jq -n \
            --arg cmd "${{ inputs.command }}" \
            --argjson args '${{ inputs.args }}' \
            '{command: $cmd, args: $args}')
          aws lambda invoke \
            --function-name myapp-${{ inputs.environment }}-admin-tasks \
            --payload "$PAYLOAD" \
            --cli-binary-format raw-in-base64-out \
            /tmp/response.json
          cat /tmp/response.json

The CDK Stack

The Lambda layer is the most complex part of the CDK stack because Python packages must be compiled for the Lambda runtime (manylinux2014_x86_64), not the CI runner:

def _create_lambda_layer(self) -> lambda_.LayerVersion:
    return lambda_.LayerVersion(
        self, "DepsLayer",
        compatible_runtimes=[lambda_.Runtime.PYTHON_3_13],
        code=lambda_.Code.from_asset(
            "..",
            bundling=cdk.BundlingOptions(
                image=lambda_.Runtime.PYTHON_3_13.bundling_image,
                command=["bash", "-c",
                    "pip install -r requirements.txt"
                    " -t /asset-output/python"
                    " --only-binary=:all:"
                    " --platform=manylinux2014_x86_64"
                    " --implementation=cp"
                    " --python-version=3.13"
                    # Strip boto3/botocore — already in Lambda runtime
                    " && rm -rf /asset-output/python/boto3"
                    " /asset-output/python/botocore"
                ],
            ),
        ),
    )

Removing boto3 and botocore from the layer saves ~50 MB — the Lambda runtime already includes them.

The full stack topology per environment:

Lambda (app) ──── Lambda Alias (dev/prod)
                          │
                    API Gateway (HTTP API)
                          │
                    CloudFront Distribution
                    ├── Default: proxy to API Gateway (no cache)
                    └── /static/*: S3 origin (optimized cache)
                          │
                    Route53 A-record → CloudFront

A warmer Lambda runs on a cron schedule to keep the app Lambda warm, preventing cold starts during business hours:

rule = events.Rule(
    self, "WarmerSchedule",
    schedule=events.Schedule.rate(cdk.Duration.minutes(5)),
)
rule.add_target(event_targets.LambdaFunction(warmer_fn))

Part 3: Flutter Integration

Auth State with BLoC

The Flutter side uses flutter_bloc to model authentication as a state machine. States are: AuthInitial → AuthLoading → Authenticated | Unauthenticated | AuthError.

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final AppAuthProvider authProvider;
  late final StreamSubscription _authSub;

  AuthBloc(this.authProvider) : super(AuthInitial()) {
    on<AuthCheckRequested>(_onAuthCheckRequested);
    on<SignInWithEmail>(_onSignInWithEmail);
    on<SignInWithGoogle>(_onSignInWithGoogle);
    on<SignOut>(_onSignOut);
    on<SyncPermissions>(_onSyncPermissions);

    // React to Supabase session changes globally
    _authSub = authProvider.authStateChanges.listen((user) {
      if (user == null && state is Authenticated) {
        add(SignOut());
      }
    });
  }

The authStateChanges stream listener is the bridge between Supabase's session management and your BLoC state. When Supabase revokes a session (e.g. after a permission update on the backend), this triggers automatically and signs the user out — they re-authenticate and get a fresh JWT with updated permissions.

API Client with Automatic JWT Injection

The API class is a thin wrapper over http that reads the current Supabase session and injects the Bearer token on every request:

class API {
  static late String baseUrl;

  static Map<String, String> _headers() {
    String? jwt;
    try {
      jwt = Supabase.instance.client.auth.currentSession?.accessToken;
    } catch (_) {}
    return {
      'Content-Type': 'application/json',
      if (jwt != null && !isJwtExpired(jwt)) 'Authorization': 'Bearer $jwt',
    };
  }

isJwtExpired decodes the JWT locally (without a network call) and checks the exp claim. If expired, no Authorization header is sent — the request fails with 401 on the backend, and the calling code can trigger a token refresh or sign-out.

static Future<ApiResponse<dynamic>> uploadFile(
  String path, {
  required String fieldName,
  required List<int> fileBytes,
  required String fileName,
}) async {
  final request = http.MultipartRequest('POST', Uri.parse(_buildUrl(path)));
  request.headers.addAll(_headers());  // inject JWT
  request.files.add(http.MultipartFile.fromBytes(fieldName, fileBytes, filename: fileName));
  final streamed = await request.send();
  final response = await http.Response.fromStream(streamed);
  return parseResponse(response);
}

Permissions in Flutter

Because permissions live in the JWT app_metadata, the Flutter model parses them on sign-in and stores them on the User object:

void initFromJwt(String? token) {
  if (token == null) return;
  final parts = token.split('.');
  final payload = jsonDecode(
    utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))
  );
  final meta = payload['app_metadata'] as Map<String, dynamic>? ?? {};
  orgId = meta['org_id'] as String?;
  orgPermissions = int.tryParse(meta['org_permissions'] as String? ?? '0') ?? 0;
  locationPermissions = (meta['location_permissions'] as Map<String, dynamic>? ?? {})
      .map((k, v) => MapEntry(k, int.tryParse(v as String) ?? 0));
}

bool hasOrgPermission(int required) => (orgPermissions & required) == required;

bool hasLocationPermission(String locationId, int required) {
  final p = locationPermissions[locationId] ?? 0;
  return (p & required) == required;
}

UI widgets use these directly to show/hide actions without a network round-trip:

if (user.hasOrgPermission(OrgPermissions.manageMembers))
  IconButton(icon: Icon(Icons.person_add), onPressed: _inviteMember),

Part 4: Flutter CI/CD

Android: Build and Publish, Decoupled

Android release is split into two workflow_dispatch workflows. The build workflow decrypts the signing keystore, runs flutter build appbundle with the chosen flavor, and attaches the AAB to a draft GitHub release. The publish workflow downloads the AAB from that release and submits it to the Play Store. Decoupling the two means a signed build can wait for QA sign-off, legal review, or a coordinated launch without touching the CI pipeline — automation builds, a human decides when to ship.

The sensitive part is keystore management. GPG-encrypt the keystore once locally and decrypt at build time with gpg --batch (the flag suppresses interactive prompts, which CI requires):

# one-time local setup
gpg --symmetric --cipher-algo AES256 --batch --passphrase "YOUR_PASS" keystore.jks
base64 keystore.jks.gpg | pbcopy   # paste into KEYSTORE_FILE_ENCRYPTED secret

Flutter Web to Vercel

name: deploy-web

on:
  workflow_dispatch:
    inputs:
      flavor:
        type: choice
        options: [dev, prod]

jobs:
  build_and_deploy:
    steps:
      - uses: subosito/flutter-action@v2

      - run: flutter pub get
      - run: flutter build web --release --dart-define=FLAVOR=${{ inputs.flavor }}

      - name: Copy Vercel config
        run: cp vercel.json build/web/vercel.json

      - name: Deploy to Vercel
        run: |
          FLAG=$([[ "${{ inputs.flavor }}" == "prod" ]] && echo "--prod" || echo "")
          vercel deploy build/web $FLAG --yes --token ${{ secrets.VERCEL_TOKEN }}
        env:
          VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
          VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

vercel.json is copied into the build output before deployment. Flutter web uses client-side routing, so all paths must route to index.html:

{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}

Putting It All Together: The Deploy Sequence

A full production release across both repos looks like this:

1. django-project:
   └── Trigger "Deploy to Prod" workflow with tag v1.4.2
       ├── Checkout tag
       ├── Bootstrap env (S3 cache hit → 15s)
       ├── CDK deploy (Lambda code + layer + infra)
       ├── Lambda invoke → migrate (same runtime, 5-min timeout)
       └── Lambda invoke → collectstatic

2. flutter-app:
   └── Trigger "build-app" with flavor=prod, tag=v1.4.2
       ├── Decrypt keystore
       ├── flutter build appbundle
       └── gh release create v1.4.2 --draft

   └── QA signs off → Trigger "publish-app"
       ├── gh release download *.aab
       └── Upload to Play Store (internal → review → production track)

   └── Trigger "deploy-web" with flavor=prod
       ├── flutter build web
       └── vercel deploy --prod

Total backend deploy time: ~8 minutes (CDK diff + Lambda update). Flutter build time: ~5 minutes (no Java/Gradle cache issues with a clean runner).


What Makes This Production-Grade

A few things that distinguish this from a tutorial setup:

No secrets in code or CI logs. Supabase keys are in SSM Parameter Store, fetched by the Lambda at runtime. The CI pipeline only holds AWS credentials — everything else is resolved by the Lambda itself.

Migrations run in the deployment environment. Using Lambda invoke means migrations use the exact same DB connection, Python version, and environment variables as the production handler. No "it passed locally" surprises.

Zero-trust permissions. Every API request is authenticated via the JWKS endpoint. Permissions are verified from the JWT payload. The database is never queried for access control on the hot path.

Tenant isolation enforced on every request. The org_id in app_metadatais server-controlled — the client cannot tamper with it — so every query the backend runs is scoped to the caller's organisation from a claim Supabase signed, not from anything the client sent.

Draft releases. Both Android and backend deploys require a human step before going live. Automation handles building and deploying; humans control shipping.

Dependency caching that actually works. The S3-based env artifact means CDK deploys are fast after the first run, even though GitHub Actions has no persistent cache across runs without explicit setup.


Common Pitfalls

Stale JWT permissions. If you update app_metadata without revoking sessions, the client holds a valid JWT with old permissions until it expires. Always revoke sessions after any permission change.

HS256 vs ES256 confusion.Supabase dashboard shows the JWT secret for HS256. If you switch to JWKS (ES256), the JWT secret is irrelevant — don't use it on the backend. Use the JWKS URL from Dashboard → Project Settings → API.

Lambda layer binary compatibility. If you pip install without the --platformflags, packages compile against your CI runner's glibc, not Lambda's. The --only-binary=:all: --platform=manylinux2014_x86_64 flags are non-negotiable for x86_64 — use manylinux2014_aarch64 for arm64 (Graviton2).

Missing Supabase redirect URLs. Mobile deep links and web redirect URLs must be listed in the Supabase dashboard. Forgetting yourapp:// causes password reset and magic link flows to break silently on device.

workflow_dispatch inputs in bash. The [[ ]] syntax in the Vercel deploy step requires bash. GitHub Actions run: defaults to bash on ubuntu-latest, but explicitly adding shell: bash makes the intent clear.


This stack has been running in production for several months. The architecture decisions — JWT-embedded permissions, Lambda-invoked migrations, S3-cached build environments, and separated build/publish workflows — each solve a specific real-world problem. Start with the CI workflow and Supabase auth, then layer in CDK and Flutter BLoC as the app grows.

Share this article