Audience: Security Review / Developers Last Updated: February 2026
This document describes the authentication and authorization architecture used in FlowWink, intended for security review and developer reference.
FlowWink uses Supabase Auth for all user authentication. The frontend is a React SPA that communicates with:
- Supabase Postgres (via PostgREST) for data
- Supabase Edge Functions (Deno runtime) for server-side logic
- Supabase Storage for file uploads
┌──────────────┐ JWT Bearer Token ┌──────────────────┐
│ Frontend │ ──────────────────────── │ Supabase Auth │
│ (React SPA) │ │ (GoTrue) │
└──────┬───────┘ └──────────────────┘
│ │
│ JWT Bearer Token │ Signs JWTs
│ │ (asymmetric JWKS)
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ Edge Functions│ ◄── verify via ────────│ JWKS Endpoint │
│ (Deno) │ getUser() call │ /.well-known/ │
└──────────────┘ └──────────────────┘
All FlowWink edge functions are deployed with verify_jwt = false. This is intentional and aligned with Supabase's current recommendation.
Supabase transitioned from symmetric JWT secrets to asymmetric JWT Signing Keys (JWKS) in 2024-2025. The edge function runtime's built-in verify_jwt mechanism was designed for the old symmetric secret and does not support the new JWKS-based verification.
Result: verify_jwt = true causes authentication failures on projects using the new JWT Signing Keys, even when the user's access token is valid.
From Supabase Edge Functions Auth Documentation:
Deploy with
--no-verify-jwtand implement manual JWT verification using thejoselibrary and the project's JWKS endpoint.
Instead of the jose JWKS approach, we use supabase.auth.getUser() which achieves the same goal through a different mechanism:
// 1. Check Authorization header exists
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
}
// 2. Create Supabase client with user's token
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
global: { headers: { Authorization: authHeader } }
});
// 3. Verify token via Supabase Auth API
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
}
// 4. Check admin role (where required)
const { data: roleData } = await supabase
.from('user_roles')
.select('role')
.eq('user_id', user.id)
.single();
if (roleData?.role !== 'admin') {
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 });
}| Aspect | Our approach (getUser()) |
JWKS (jose library) |
|---|---|---|
| Token verification | Server-side API call to Auth | Local cryptographic verification |
| Latency | ~10-50ms extra (network call) | ~1-5ms (local) |
| Token revocation | Immediate (checks live state) | Delayed (until token expires) |
| Admin role check | Separate DB query | Would need separate DB query too |
| Complexity | Simple, well-understood | More setup, JWKS caching |
| Security | ✅ Strong | ✅ Strong |
Trade-off: Our approach has slightly higher latency per request but catches revoked tokens immediately. Since we need a DB query for admin role verification anyway, the practical difference is minimal.
If latency becomes a concern, we can migrate to JWKS verification:
import * as jose from "jsr:@panva/jose@6";
const JWKS = jose.createRemoteJWKSet(
new URL(Deno.env.get("SUPABASE_URL") + "/auth/v1/.well-known/jwks.json")
);
async function verifyJWT(jwt: string) {
return jose.jwtVerify(jwt, JWKS, {
issuer: Deno.env.get("SUPABASE_URL") + "/auth/v1"
});
}This is a low-priority optimization, not a security fix.
Not all edge functions require the same level of authentication:
check-secrets— Reads secret presence (not values)create-user— Creates new usersgenerate-text— AI text generationchat-completion— AI chatnewsletter-send— Sends newsletterscopilot-action— FlowPilot actionsmigrate-page— Content migrationanalyze-brand— Brand analysisqualify-lead— Lead qualificationenrich-company— Company enrichment
process-image— Image processingunsplash-search— Stock photo search
content-api— Public content API (read-only)get-page— Public page renderingblog-rss— RSS feedllms-txt— LLM discovery filesitemap-xml— Sitemaptrack-page-view— Analytics trackingnewsletter-subscribe— Newsletter signupnewsletter-track— Email open trackingnewsletter-link— Link click trackingstripe-webhook— Stripe webhook (verified by Stripe signature)
stripe-webhook— Verified viaStripe-Signatureheadernewsletter-gdpr— Token-based verification
All Supabase tables use Row Level Security policies. Key patterns:
- Public read: Published content is readable by anyone
- Write: Only authenticated users with appropriate roles
- Read/Write: Only admin users
- Read own: Users can read their own role
- Write: Only admins can modify roles
- Uses
VITE_SUPABASE_PUBLISHABLE_KEY(anon key) - This key is public and safe to expose
- All access is controlled by RLS policies
- Have access to
SUPABASE_SERVICE_ROLE_KEYvia environment - This key bypasses RLS — used only for admin operations
- Never exposed to the client
- Stored as Supabase secrets (encrypted at rest)
- Only accessible from edge function runtime
check-secretsfunction reports presence (not values) to admin UI
All edge functions include CORS headers:
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};Note: Access-Control-Allow-Origin: '*' is acceptable because:
- Authentication is handled via Bearer tokens, not cookies
- No sensitive data is exposed without valid authentication
- This is the standard Supabase edge function pattern
# All functions deployed with --no-verify-jwt
supabase functions deploy <function-name> --no-verify-jwt
# Configured in supabase/config.toml
[functions.<name>]
verify_jwt = falseEven public functions use verify_jwt = false because:
- The built-in verification is incompatible with JWKS
- Public functions don't need JWT verification at all
- Consistency — all auth is handled in function code
-
getUser()latency — Each authenticated request makes an API call to verify the token. Could be optimized with JWKS local verification. -
CORS wildcard —
Access-Control-Allow-Origin: '*'is broad. Could be restricted to specific domains in production, but adds deployment complexity for self-hosters. -
No rate limiting on edge functions — Supabase provides basic rate limiting, but no custom per-function limits are implemented.
FlowWink uses a custom logger utility that automatically disables debug logging in production:
import { logger } from '@/lib/logger';
// Only logs in development
logger.log('Debug info');
logger.warn('Warning message');
// Always logs (even in production)
logger.error('Critical error');Why this matters:
- Debug logs (
log,warn,debug) are silent in production - Error logs (
error) are always active for critical issues - No sensitive data leaks through console output
- Improved performance (no unnecessary string interpolation in production)
When creating new edge functions:
- Add Authorization header check for protected functions
- Use
supabase.auth.getUser()to verify the token - Check admin role via
user_rolestable if admin-only - Never expose
SUPABASE_SERVICE_ROLE_KEYin responses - Never return secret values (only presence checks)
- Add CORS headers for browser compatibility
- Handle errors without leaking internal details
- Log security events (failed auth, forbidden access)
Last updated: February 2026 Applies to: FlowWink v1.x with Supabase cloud backend