Skip to main content

Overview

Athena ERP uses Supabase Auth as its identity provider. While Supabase handles user authentication (login, signup, password reset), Athena maintains its own authorization model in PostgreSQL. This hybrid approach provides:
  • ✅ Battle-tested authentication flows (OAuth, magic links, MFA)
  • ✅ Flexible authorization with school-specific roles
  • ✅ Database-driven permission resolution
  • ✅ Multi-tenant support via school memberships

Architecture

┌─────────────┐         ┌──────────────┐         ┌──────────────┐
│   Client    │         │   Supabase   │         │  Athena API  │
│             │────────▶│     Auth     │         │              │
│             │  Login  │              │         │              │
└─────────────┘         └──────────────┘         └──────────────┘
       │                        │                        │
       │                        │                        │
       │◀───────────────────────┘                        │
       │       JWT Token                                 │
       │                                                 │
       │─────────────────────────────────────────────────▶
       │            Request + Bearer Token               │
       │                                                 │
       │                                         ┌───────▼────────┐
       │                                         │  JWT Validation│
       │                                         │  + DB Lookup   │
       │                                         └───────┬────────┘
       │                                                 │
       │◀─────────────────────────────────────────────────┘
       │              Authorized Response

JWT Validation Process

Athena implements a two-tier validation strategy to handle JWT tokens reliably:

Tier 1: Local JWT Decode

The API first attempts to decode the JWT using the shared JWT_SECRET:
# app/auth/jwt.py:26-37
payload = jwt.decode(
    token,
    settings.jwt_secret,
    algorithms=[settings.jwt_algorithm],
)
token
string
required
The raw JWT token from the Authorization: Bearer header
settings.jwt_secret
string
required
The JWT secret shared with Supabase (from SUPABASE_JWT_SECRET)
settings.jwt_algorithm
string
default:"HS256"
The signing algorithm (must match Supabase configuration)

Tier 2: Supabase User Info Endpoint

If local decoding fails (e.g., during key rotation), Athena falls back to Supabase’s /auth/v1/user endpoint: Implementation: app/auth/jwt.py:64-98
response = httpx.get(
    f"{base_url}/auth/v1/user",
    headers={
        "apikey": settings.supabase_anon_key,
        "Authorization": f"Bearer {token}",
    },
    timeout=10.0,
)
This endpoint validates the token server-side and returns the user payload.
The Supabase URL is never inferred from the token. It must be explicitly configured in SUPABASE_URL to prevent SSRF attacks.

Issuer Verification

After decoding, Athena verifies the token’s issuer matches the configured Supabase project:
# app/auth/jwt.py:42-45
expected_issuer = f"{settings.supabase_url}/auth/v1"
if payload.get("iss") != expected_issuer:
    raise HTTPException(401, "Token inválido")
This prevents accepting tokens from other Supabase projects or malicious issuers.

Token Payload Mapping

Athena extracts the following fields from Supabase JWT:

Standard Claims

sub
string
required
User ID in UUID format. Used to lookup the user in Athena’s users table.
email
string
required
User’s email address (synced with Supabase).
iss
string
required
JWT issuer. Must be {SUPABASE_URL}/auth/v1.

App Metadata (Optional)

app_metadata.school_id
string
Suggested school context (UUID). The API validates this against school_memberships.
app_metadata.roles
array
JWT-level roles (e.g., ["superadmin"]). Combined with database roles for authorization.
app_metadata is set in Supabase during user creation or updated via Supabase Management API. It provides hints but is not the source of truth for authorization.

Database Authorization

After JWT validation, Athena resolves the user’s actual permissions by querying the local database:

Step 1: Fetch User

# app/deps.py:49-73
result = await db.execute(
    select(User).where(User.id == uuid.UUID(payload.sub))
)
user = result.scalar_one_or_none()

if not user:
    raise HTTPException(401, "Usuario no encontrado en la base local")
if not user.is_active:
    raise HTTPException(403, "Usuario inactivo")

Step 2: Fetch School Memberships

# app/deps.py:82-88
memberships = await db.execute(
    select(SchoolMembership).where(
        SchoolMembership.user_id == current_user.id,
        SchoolMembership.is_active == True,
    )
)

Step 3: Resolve School Context

The active school is determined by (in order):
  1. X-School-Id header (explicit)
  2. JWT app_metadata.school_id (fallback)
  3. Single membership (automatic)
# app/deps.py:91-96
requested_school_id = x_school_id or payload.school_id

if requested_school_id:
    membership = memberships_by_school.get(requested_school_id)
    if not membership and not has_permission(payload.roles, "manage:schools"):
        raise HTTPException(403, "No tienes acceso al colegio solicitado")

Step 4: Build Auth Context

The final AuthContext contains:
user
User
The authenticated user object from the database.
payload
TokenPayload
The decoded JWT payload.
membership
SchoolMembership
The active school membership (if a school is selected).
school
School
The active school object (if a school is selected).
memberships
array
All active school memberships for the user.
roles
array
Combined roles from JWT app_metadata.roles and membership.roles.

Configuration

Required Environment Variables

# Supabase Project URL
SUPABASE_URL=https://xyzcompany.supabase.co

# Supabase Anonymous Key (for client-side auth)
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

# Supabase Service Role Key (for admin operations)
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

# JWT Secret (MUST match Supabase project JWT secret)
JWT_SECRET=your-super-secret-jwt-secret-from-supabase
JWT_ALGORITHM=HS256
You can find your Supabase JWT secret in the Supabase Dashboard under Settings → API → JWT Settings.

Development vs Production

Development:
# .env.development
SUPABASE_URL=http://localhost:54321  # Local Supabase
JWT_SECRET=super-secret-local-dev-key-change-in-prod
Production:
# .env.production
SUPABASE_URL=https://your-project.supabase.co
JWT_SECRET=<actual-supabase-jwt-secret>
SUPABASE_ANON_KEY=<actual-anon-key>
SUPABASE_SERVICE_ROLE_KEY=<actual-service-role-key>
Never commit production secrets to version control. Use environment-specific .env files or a secrets manager.

Endpoint: GET /auth/me

Retrieve the authenticated user’s profile, including roles and school memberships.

Request

curl -X GET 'https://api.athena.edu.co/api/v1/auth/me' \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
  -H 'X-School-Id: 550e8400-e29b-41d4-a716-446655440000'

Headers

Authorization
string
required
Bearer token from Supabase authentication.Format: Bearer {access_token}
X-School-Id
string
Optional UUID of the school to use as context. Required if user has multiple memberships.

Response

id
string
User’s UUID.
email
string
User’s email address.
full_name
string
User’s full name.
is_active
boolean
Whether the user account is active.
roles
array
Combined roles from JWT and active school membership.
school_id
string
UUID of the active school (if one is selected).
memberships
array
List of all active school memberships.

Example Response

{
  "id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "email": "[email protected]",
  "full_name": "María García",
  "is_active": true,
  "roles": ["teacher", "coordinator"],
  "school_id": "550e8400-e29b-41d4-a716-446655440000",
  "memberships": [
    {
      "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "school_id": "550e8400-e29b-41d4-a716-446655440000",
      "roles": ["teacher", "coordinator"],
      "is_active": true,
      "created_at": "2024-01-15T08:30:00Z",
      "updated_at": "2024-03-10T14:20:00Z"
    },
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "school_id": "660f9511-f3ac-52e5-b827-557766551111",
      "roles": ["teacher"],
      "is_active": true,
      "created_at": "2024-02-01T10:00:00Z",
      "updated_at": "2024-02-01T10:00:00Z"
    }
  ]
}

Error Responses

401 Unauthorized
error
Token is missing, invalid, or expired.
{
  "detail": "Token inválido, expirado o malformado"
}
403 Forbidden
error
User account is inactive or lacks permission to access the requested school.
{
  "detail": "Usuario inactivo"
}
400 Bad Request
error
User has multiple memberships but no X-School-Id header provided.
{
  "detail": "Debes enviar el header X-School-Id para elegir un colegio"
}

Best Practices

Token Refresh

Supabase tokens expire after 1 hour by default. Implement token refresh in your client:
// Example with @supabase/supabase-js
const { data, error } = await supabase.auth.refreshSession()
if (data?.session) {
  const newAccessToken = data.session.access_token
  // Use newAccessToken for API calls
}

Multi-Tenant Requests

For users with multiple school memberships, always include X-School-Id:
# Good: Explicit school context
curl -H "X-School-Id: 550e8400-e29b-41d4-a716-446655440000" \
     -H "Authorization: Bearer {token}" \
     https://api.athena.edu.co/api/v1/students

Error Handling

Always handle 401 and 403 errors by redirecting to login or showing an error message:
try {
  const response = await fetch('/api/v1/auth/me', {
    headers: { 'Authorization': `Bearer ${token}` }
  })
  if (response.status === 401) {
    // Token expired - redirect to login
    window.location.href = '/login'
  }
} catch (error) {
  console.error('Authentication failed:', error)
}

Security Considerations

JWT Secret Management

  • Never expose JWT_SECRET to clients
  • Rotate secrets periodically using Supabase dashboard
  • Use environment variables instead of hardcoding

Token Storage

  • Store tokens in httpOnly cookies (preferred) or secure localStorage
  • Never store tokens in URL parameters
  • Clear tokens on logout

SSRF Protection

Athena prevents Server-Side Request Forgery by:
  • Never parsing the iss claim to make HTTP requests
  • Only using server-configured SUPABASE_URL
  • Validating the issuer against a whitelist

Rate Limiting

Consider implementing rate limiting on authentication endpoints to prevent:
  • Brute force attacks
  • Token enumeration
  • DDoS attempts

Troubleshooting

”Token inválido, expirado o malformado”

Causes:
  • Token has expired (>1 hour old)
  • JWT secret mismatch between Athena and Supabase
  • Token was issued by a different Supabase project
Solutions:
  1. Refresh the token using Supabase client
  2. Verify JWT_SECRET matches Supabase project settings
  3. Check SUPABASE_URL points to the correct project

”Usuario no encontrado en la base local”

Cause: User exists in Supabase but not in Athena’s database. Solution: Create a user record in Athena’s users table:
INSERT INTO users (id, email, full_name, is_active)
VALUES (
  'uuid-from-supabase-auth',
  '[email protected]',
  'User Name',
  true
);

“Debes enviar el header X-School-Id para elegir un colegio”

Cause: User has multiple school memberships but no school was specified. Solution: Include X-School-Id header in all requests:
curl -H "X-School-Id: 550e8400-e29b-41d4-a716-446655440000" ...

Authentication Overview

Learn about Athena’s authentication architecture

Permissions Reference

View the complete role and permission matrix

Supabase Documentation

Official Supabase Auth documentation

Build docs developers (and LLMs) love