Security
Security — RBAC and field-level authorization
FraiseQL supports multiple OAuth2/OIDC providers for authentication, with built-in support for popular identity platforms.
| Provider | Protocol | Features |
|---|---|---|
| OIDC | Email verification, profile | |
| GitHub | OAuth2 | Org membership, teams |
| Microsoft Azure AD | OIDC | Tenant isolation, groups |
| Okta | OIDC | Custom claims, MFA |
| Auth0 | OIDC | Rules, roles, permissions |
| Keycloak | OIDC | Self-hosted, realm support |
| Generic OIDC | OIDC | Any compliant provider |
OAuth provider credentials are configured via environment variables — there is no [auth] section in fraiseql.toml.
GOOGLE_CLIENT_ID=your-client-idGOOGLE_CLIENT_SECRET=your-client-secretOAUTH_REDIRECT_URI=https://api.example.com/auth/callbackGITHUB_CLIENT_ID=your-client-idGITHUB_CLIENT_SECRET=your-client-secretOAUTH_REDIRECT_URI=https://api.example.com/auth/callbackAZURE_CLIENT_ID=your-client-idAZURE_CLIENT_SECRET=your-client-secretAZURE_TENANT_ID=your-tenant-idOAUTH_REDIRECT_URI=https://api.example.com/auth/callbackOKTA_CLIENT_ID=your-client-idOKTA_CLIENT_SECRET=your-client-secretOKTA_DOMAIN=your-domain.okta.comOAUTH_REDIRECT_URI=https://api.example.com/auth/callbackAUTH0_CLIENT_ID=your-client-idAUTH0_CLIENT_SECRET=your-client-secretAUTH0_DOMAIN=your-tenant.auth0.comAUTH0_AUDIENCE=https://api.example.comOAUTH_REDIRECT_URI=https://api.example.com/auth/callbackKEYCLOAK_CLIENT_ID=your-client-idKEYCLOAK_CLIENT_SECRET=your-client-secretKEYCLOAK_SERVER_URL=https://keycloak.example.comKEYCLOAK_REALM=my-realmOAUTH_REDIRECT_URI=https://api.example.com/auth/callbackOIDC_CLIENT_ID=your-client-idOIDC_CLIENT_SECRET=your-client-secretOIDC_ISSUER=https://identity.example.comOAUTH_REDIRECT_URI=https://api.example.com/auth/callback# Discovery URL auto-detected from issuer (/.well-known/openid-configuration)| Endpoint | Purpose |
|---|---|
/auth/login | Initiate OAuth flow |
/auth/callback | OAuth callback handler |
/auth/logout | End session |
/auth/refresh | Refresh access token |
/auth/userinfo | Get current user info |
Sessions are stored in PostgreSQL or Redis. Configuration is via environment variables:
SESSION_STORAGE=postgres # postgres | redis | memorySESSION_TTL_SECONDS=86400 # 24 hoursREFRESH_TTL_SECONDS=604800 # 7 daysREDIS_URL=redis://host:6379/0 # required when SESSION_STORAGE=redisPKCE (Proof Key for Code Exchange) is configured via [security.pkce] in fraiseql.toml — see Security.
State blobs are encrypted via [security.state_encryption] in fraiseql.toml — see Security.
Roles are read from the roles claim in the JWT returned by your provider. JWT claim names are configured via environment variables:
JWT_ROLES_CLAIM=roles # JWT field containing roles (default: roles)JWT_DEFAULT_ROLE=user # fallback if no roles claim presentSet the active provider via environment variable:
OAUTH_PROVIDER=google # active providerOAUTH_DEFAULT_PROVIDER=google # fallback when ?provider= not specifiedLogin endpoint accepts provider parameter:
/auth/login?provider=github/auth/login?provider=google# In resolvers, access the authenticated user@fraiseql.query(sql_source="v_user")def me(context: Context) -> User: user_id = context.user_id # From token roles = context.roles # Mapped roles email = context.email # From claimsimport { query, type Context } from 'fraiseql';
// In resolvers, access the authenticated user@query({ sqlSource: 'v_user' })function me(context: Context): User { const userId = context.userId; // From token const roles = context.roles; // Mapped roles const email = context.email; // From claims}// In resolvers, access the authenticated user from contextfunc me(ctx context.Context) (*User, error) { userID, ok := ctx.Value("user_id").(string) if !ok { return nil, fmt.Errorf("user not authenticated") }
roles, _ := ctx.Value("roles").([]string) email, _ := ctx.Value("email").(string)
return &User{ ID: userID, Email: email, }, nil}Custom claim namespacing (used by Auth0, Okta, etc.) is handled automatically — FraiseQL extracts claims from the JWT and makes them available in context. Access in context:
tenant_id = context.claims.get("tenant_id")const tenantId = context.claims.get('tenant_id');// Access custom claims from contextfunc getTenantID(ctx context.Context) (string, error) { claims, ok := ctx.Value("claims").(map[string]interface{}) if !ok { return "", fmt.Errorf("no claims in context") }
tenantID, ok := claims["tenant_id"].(string) if !ok { return "", fmt.Errorf("tenant_id not found in claims") }
return tenantID, nil}After configuring a provider, verify the full authorization code exchange manually before wiring up a frontend.
Start the login flow — open the login URL in a browser or follow the redirect:
curl -v http://localhost:8080/auth/login?provider=google# Follow the Location header to the Google authorization pageAuthorize in the browser — log in and approve the consent screen. Your browser is redirected back to the callback URL with a code parameter.
Exchange the authorization code — FraiseQL handles this automatically via the callback endpoint. To test it directly:
curl -X POST http://localhost:8080/auth/callback \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "code=AUTHORIZATION_CODE&state=STATE_VALUE&provider=google"Inspect the token response — a successful exchange returns a session token:
{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 86400, "refresh_token": "rt_01HV3KQBN7MXSZQZQR4F5P0G2Y", "user": { "id": "usr_01HV3KQBN7MXSZQZQR4F5P0G2Y", "email": "alice@example.com", "name": "Alice Smith", "roles": ["user"] }}Call a protected query using the returned token:
curl http://localhost:8080/graphql \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ -H "Content-Type: application/json" \ -d '{"query":"{ me { id email roles } }"}'| HTTP Status | Error | Cause | Fix |
|---|---|---|---|
400 Bad Request | invalid_client | Client ID or secret is wrong | Verify client_id and client_secret match the provider app settings |
400 Bad Request | redirect_uri_mismatch | Callback URL not registered | Add redirect_uri exactly as configured to the provider’s allowed redirect list |
400 Bad Request | invalid_grant | Authorization code already used or expired | Codes are single-use and expire quickly (usually 60 seconds); do not reuse or delay exchange |
401 Unauthorized | invalid_token | Access token expired or revoked | Use /auth/refresh with the refresh token to obtain a new access token |
403 Forbidden | access_denied | User denied consent or lacks required scope | Check required scopes in fraiseql.toml; ensure the user approves all requested permissions |
403 Forbidden | org_required | User is not a member of required_org (GitHub) | Verify required_org value; user must be a member of that GitHub organization |
500 Internal Server Error | token_exchange_failed | Network error reaching the provider | Check outbound connectivity; verify issuer URL and discovery_url are reachable |
| Metric | Description |
|---|---|
fraiseql_auth_logins_total | Login attempts |
fraiseql_auth_login_success_total | Successful logins |
fraiseql_auth_login_failure_total | Failed logins |
fraiseql_auth_token_refresh_total | Token refreshes |
fraiseql_auth_session_active | Active sessions |
Ensure redirect URI matches exactly in:
FraiseQL maps JWT claims to user roles automatically. Given this token (decoded):
{ "sub": "usr_123", "email": "alice@example.com", "roles": ["admin", "editor"], "exp": 1740787200, "iss": "https://auth.example.com"}FraiseQL grants access to all resolvers decorated with @role("admin") or @role("editor"). The roles claim field is configured via the JWT_ROLES_CLAIM environment variable (default: roles).
Security
Security — RBAC and field-level authorization
Rate Limiting
Rate Limiting — Protect auth endpoints
Deployment
Deployment — Production OAuth setup