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
Repos:
django-project/โ Django + DRF, AWS CDK, GitHub Actions CI + deployflutter-app/โ Flutter (Android, iOS, web), GitHub Actions build + publish
| Concern | Choice | Why |
|---|---|---|
| Auth | Supabase (ES256 JWT) | Handles OAuth, magic links, email, password reset out of the box |
| Permissions | Embedded in JWT app_metadata | Zero DB round-trips per request; consistent across client and server |
| Compute | AWS Lambda (Python 3.13) | No servers to manage; scales to zero |
| IaC | AWS CDK (Python) | Full Python stack, typed constructs |
| Client state | Flutter BLoC | Predictable auth state machine |
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.
# common/auth/supabase.py
from jwt import PyJWKClient
JWKSClient = PyJWKClient(
settings.SUPABASE_JWKS_URL,
cache_keys=True, # caches the JWKS in-process
)cache_keys=True is important for Lambda: each warm instance maintains a local key cache, avoiding a JWKS fetch on every request.
The Authentication Backend
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, tokenTwo things to notice:
get_or_createon the DjangoUsermodel โ 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.- 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"
}
}
}Permissions are stored as strings โ not integers โ because mobile clients (Flutter) and web clients (JavaScript) both handle large integers inconsistently. The backend always casts to int() before bitwise operations.
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)The important detail: after updating app_metadata, you must call the logout endpoint. This revokes the user's refresh tokens, forcing the client to re-authenticate and receive a new JWT with updated permissions. Without this step, the old JWT stays valid until expiry โ the user operates with stale permissions.
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) == requiredThe check is (user & required) == required โ not just user & required. The difference matters: user & required is truthy for any bit overlap, but == required only passes when all required bits are set. Always use the latter.
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:
| Field | Value |
|---|---|
| Site URL | https://app.yourdomain.com |
| Redirect URLs | One 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 devWithout this, email links in production redirect to localhost and silently fail on mobile.
Part 2: Backend CI/CD with GitHub Actions
The backend has four workflow files with a clean separation of concerns.
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 testThe --health-cmd pg_isready block is essential โ without it, the test step can start before Postgres is ready to accept connections.
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: inheritProduction 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.
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: ${{ inputs.artifacts_bucket }}
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.jsonKey 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
fiThis 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.jsonThe 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"
],
),
),
)The --only-binary=:all: flag forces pip to use pre-built wheels. Combined with the manylinux2014_x86_64 platform target, you get binary extensions compiled for Lambda's glibc environment, not your developer machine.
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 โ CloudFrontA 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.cron(hour="0", minute="0"),
)
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)));
// 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
Workflow 1: Build Android AAB
The Android build pipeline handles the most sensitive part: keystore management. The keystore is GPG-encrypted and stored as a GitHub secret, decrypted at build time:
name: build-app
on:
workflow_dispatch:
inputs:
flavor:
type: choice
options: [dev, prod]
tag:
description: "Release tag (e.g. v1.0.0)"
release_name:
description: "Release name"
jobs:
build_aab:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: "17"
distribution: "microsoft"
- name: Decode and decrypt keystore
run: |
echo "${{ secrets.KEYSTORE_FILE_ENCRYPTED }}" > android/keystore.jks.asc
gpg -d \
--passphrase "${{ secrets.JKS_FILE_DECRYPTION_PASSWORD }}" \
--batch \
android/keystore.jks.asc > android/keystore.jks
- name: Write key.properties
run: echo "${{ secrets.KEY_PROPERTIES }}" > android/key.properties
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter pub get
- run: flutter build appbundle --release --dart-define=FLAVOR=${{ inputs.flavor }}
- uses: actions/upload-artifact@v4
with:
name: app-release.aab
path: build/app/outputs/bundle/release/app-release.aab
create_release:
needs: build_aab
steps:
- uses: actions/download-artifact@v4
with:
name: app-release.aab
- name: Create GitHub release (draft)
run: gh release create ${{ inputs.tag }} app-release.aab -t "${{ inputs.release_name }}" --draft
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}The GPG pattern for keystores:
Never store a raw JKS in GitHub Secrets โ the 64 KB limit is tight and the binary doesn't survive encoding reliably. Instead:
# 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 secretThe --batch flag suppresses interactive prompts, which is required for CI. Builds are released as drafts โ a human reviews and publishes.
Workflow 2: 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" }]
}Workflow 3: Publish to Play Store
Publish is a separate workflow from build. The build creates a GitHub release (draft); the publish workflow downloads the artifact from that release and submits to Play Store. This decouples "we have a build" from "we're releasing it" โ you can hold a build for QA, legal review, or a coordinated launch without touching the CI pipeline.
The Google service account credentials follow the same GPG pattern as the keystore.
name: publish-app
on: workflow_dispatch
jobs:
publish_to_playstore:
steps:
- name: Download latest release AAB
run: gh release download -p '*.aab'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish to Play Store
uses: your-org/google-play-publish@main
with:
config_file: .release/android.json
playstore_encrypted_file: ${{ secrets.G_CLIENT_SECRETS_FILE }}
playstore_decryption_pwd: ${{ secrets.G_CLIENT_SECRETS_PWD }}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 --prodTotal 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.
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.
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.