Introduction
The AI Tender Opportunity Scanner can now:
- Ingest tender opportunities
- Analyze tenders with AI
- Match opportunities to business profiles
- Rank relevant tenders
- Manage saved opportunities
- Track bid pipeline stages
- Generate analytics
- Monitor deadlines
- Send in-app and email notifications
- Deliver alerts to Slack and Microsoft Teams
The application now supports most of the operational workflow required to discover and manage tender opportunities.
However, the platform still needs a secure identity and access-management layer.
Without authentication and authorization, the application cannot reliably determine:
- Who is using the system
- Which organization the user belongs to
- Which tenders the user may access
- Who may invite team members
- Who may configure integrations
- Who may manage business profiles
- Who may update or delete pipeline records
- Which data belongs to which customer
This becomes especially important when the platform evolves from a single-user prototype into a multi-user SaaS product.
In this article, we will build:
- User registration
- Secure login
- Password hashing
- Access tokens
- Refresh tokens
- Organization accounts
- Team membership
- Invitation workflows
- Role-based access control
- Protected FastAPI routes
- Tenant-level data isolation
- React authentication state
- Protected frontend routes
- Security testing
This creates the foundation for a secure multi-tenant AI Tender Opportunity Scanner.
Development Progress
Project Scope and Roadmap ████████████████████ 100%MVP Definition ████████████████████ 100%Technology Stack ████████████████████ 100%System Architecture ████████████████████ 100%Database Design ████████████████████ 100%Backend Project Structure ████████████████████ 100%SQLAlchemy Models ████████████████████ 100%Alembic Migration ████████████████████ 100%Repository Layer ████████████████████ 100%CRUD Service Layer ████████████████████ 100%Tender Ingestion Pipeline ████████████████████ 100%AI Analysis Pipeline ████████████████████ 100%AI Matching Engine ████████████████████ 100%React Dashboard ████████████████████ 100%Tender Detail Page ████████████████████ 100%Saved Opportunities Workspace ████████████████████ 100%Pipeline Analytics Dashboard ████████████████████ 100%Notifications & Monitoring ████████████████████ 100%Email Notifications ████████████████████ 100%Slack & Teams Integrations ████████████████████ 100%Authentication & Organizations ████████████████████ 100%Multi-Tenant Data Isolation ░░░░░░░░░░░░░░░░░░░░ 0%
What We Are Building
The platform will support organization accounts containing multiple users.
Example:
Northstar Consulting│├── Ben Kemp│ └── Organization Owner│├── Anna de Vries│ └── Bid Manager│├── Mark Jansen│ └── Contributor│└── Sophie Peters └── Viewer
Each organization will have its own:
- Business profiles
- Tender matches
- Saved opportunities
- Pipeline records
- Notifications
- Analytics
- Email preferences
- Slack integrations
- Microsoft Teams integrations
- Team members
Users from one organization must never see data belonging to another organization.
Authentication Versus Authorization
These concepts are related but different.
Authentication answers:
Who are you?
Authorization answers:
What are you allowed to do?
Example:
Authentication:Ben successfully logs in.Authorization:Ben may manage organization settings because he is an owner.
A secure system requires both.
Organization-Based Multi-Tenancy
The organization becomes the primary tenant boundary.
SaaS Platform | +---- Organization A | | | +---- Users | +---- Tenders | +---- Opportunities | +---- Integrations | +---- Organization B | +---- Users +---- Tenders +---- Opportunities +---- Integrations
Most organization-owned tables should include:
organization_id
This field is used to isolate customer data.
Authentication Architecture
React Login Form | vFastAPI Authentication API | vAuthentication Service | +---- User Repository +---- Password Hasher +---- Token Service | vPostgreSQL
Protected request flow:
React Request | vAccess Token | vFastAPI Dependency | vLoad Current User | vCheck Organization and Role | vExecute Route
Core Authentication Components
The authentication system will contain:
User modelOrganization modelOrganization membership modelInvitation modelPassword hashing serviceToken serviceAuthentication serviceAuthorization policiesFastAPI dependenciesFrontend authentication context
Keeping these responsibilities separate makes the system easier to test and maintain.
Organization Model
Create:
app/models/organization.py
Recommended fields:
idnameslugstatuscreated_atupdated_at
Example:
from datetime import datetimefrom enum import Enumfrom sqlalchemy import ( DateTime, Enum as SqlEnum, Integer, String,)from sqlalchemy.orm import Mapped, mapped_columnfrom app.database.base import Baseclass OrganizationStatus(str, Enum): ACTIVE = "active" SUSPENDED = "suspended" CANCELLED = "cancelled"
class Organization(Base): __tablename__ = "organizations" id: Mapped[int] = mapped_column( Integer, primary_key=True, ) name: Mapped[str] = mapped_column( String(255), nullable=False, ) slug: Mapped[str] = mapped_column( String(255), unique=True, nullable=False, index=True, ) status: Mapped[OrganizationStatus] = mapped_column( SqlEnum( OrganizationStatus, name="organization_status", ), default=OrganizationStatus.ACTIVE, nullable=False, index=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False, ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, )
Why Use an Organization Slug?
A slug is a URL-friendly identifier.
Example:
Organization name:Northstar ConsultingSlug:northstar-consulting
It can later support URLs such as:
/app/northstar-consulting/dashboard
The internal database ID should still remain the primary key.
User Model
Create:
app/models/user.py
Recommended fields:
idemailhashed_passwordfirst_namelast_nameis_activeemail_verifiedlast_login_atcreated_atupdated_at
Example:
class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column( Integer, primary_key=True, ) email: Mapped[str] = mapped_column( String(320), unique=True, nullable=False, index=True, ) hashed_password: Mapped[str] = mapped_column( String(255), nullable=False, ) first_name: Mapped[str] = mapped_column( String(100), nullable=False, ) last_name: Mapped[str] = mapped_column( String(100), nullable=False, ) is_active: Mapped[bool] = mapped_column( Boolean, default=True, nullable=False, ) email_verified: Mapped[bool] = mapped_column( Boolean, default=False, nullable=False, ) last_login_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False, ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, )
Never store plain-text passwords.
Organization Membership Model
A user may eventually belong to more than one organization.
Instead of storing a single organization_id directly on the user, create a membership table.
Create:
app/models/organization_membership.py
Fields:
idorganization_iduser_idrolestatusjoined_atcreated_at
Organization Roles
Define initial roles:
owneradministratorbid_managercontributorviewer
Example enum:
class OrganizationRole(str, Enum): OWNER = "owner" ADMINISTRATOR = "administrator" BID_MANAGER = "bid_manager" CONTRIBUTOR = "contributor" VIEWER = "viewer"
Membership status:
class MembershipStatus(str, Enum): ACTIVE = "active" INVITED = "invited" SUSPENDED = "suspended" REMOVED = "removed"
Membership Model
class OrganizationMembership(Base): __tablename__ = "organization_memberships" __table_args__ = ( UniqueConstraint( "organization_id", "user_id", name="uq_organization_user_membership", ), ) id: Mapped[int] = mapped_column( Integer, primary_key=True, ) organization_id: Mapped[int] = mapped_column( ForeignKey( "organizations.id", ondelete="CASCADE", ), nullable=False, index=True, ) user_id: Mapped[int] = mapped_column( ForeignKey( "users.id", ondelete="CASCADE", ), nullable=False, index=True, ) role: Mapped[OrganizationRole] = mapped_column( SqlEnum( OrganizationRole, name="organization_role", ), default=OrganizationRole.CONTRIBUTOR, nullable=False, index=True, ) status: Mapped[MembershipStatus] = mapped_column( SqlEnum( MembershipStatus, name="membership_status", ), default=MembershipStatus.ACTIVE, nullable=False, index=True, ) joined_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False, )
Why Use a Membership Table?
A membership table supports:
- Multiple users per organization
- Multiple organizations per user
- Organization-specific roles
- Suspended memberships
- Invitation states
- Future external collaborators
Example:
Ben├── Owner at Northstar Consulting└── Viewer at Procurement Network Europe
The user’s permissions can differ by organization.
Role Permission Matrix
Define permissions clearly before writing authorization code.
Example:
| Permission | Owner | Administrator | Bid Manager | Contributor | Viewer |
|---|---|---|---|---|---|
| View tenders | Yes | Yes | Yes | Yes | Yes |
| Save opportunities | Yes | Yes | Yes | Yes | No |
| Edit pipeline stages | Yes | Yes | Yes | Yes | No |
| Submit bid decisions | Yes | Yes | Yes | No | No |
| Manage business profiles | Yes | Yes | Yes | No | No |
| Invite users | Yes | Yes | No | No | No |
| Manage integrations | Yes | Yes | No | No | No |
| Change roles | Yes | Yes | No | No | No |
| Delete organization | Yes | No | No | No | No |
| View analytics | Yes | Yes | Yes | Yes | Yes |
This matrix becomes the reference for backend policies and frontend visibility.
Permission-Based Authorization
Roles are useful, but permission checks are more flexible.
Define permissions:
class Permission(str, Enum): TENDER_VIEW = "tender:view" OPPORTUNITY_CREATE = "opportunity:create" OPPORTUNITY_UPDATE = "opportunity:update" OPPORTUNITY_DELETE = "opportunity:delete" BID_DECISION_MANAGE = "bid_decision:manage" PROFILE_MANAGE = "profile:manage" ANALYTICS_VIEW = "analytics:view" MEMBER_INVITE = "member:invite" MEMBER_MANAGE = "member:manage" INTEGRATION_MANAGE = "integration:manage" ORGANIZATION_MANAGE = "organization:manage"
Map roles to permissions:
ROLE_PERMISSIONS = { OrganizationRole.OWNER: set(Permission), OrganizationRole.ADMINISTRATOR: { Permission.TENDER_VIEW, Permission.OPPORTUNITY_CREATE, Permission.OPPORTUNITY_UPDATE, Permission.OPPORTUNITY_DELETE, Permission.BID_DECISION_MANAGE, Permission.PROFILE_MANAGE, Permission.ANALYTICS_VIEW, Permission.MEMBER_INVITE, Permission.MEMBER_MANAGE, Permission.INTEGRATION_MANAGE, Permission.ORGANIZATION_MANAGE, }, OrganizationRole.BID_MANAGER: { Permission.TENDER_VIEW, Permission.OPPORTUNITY_CREATE, Permission.OPPORTUNITY_UPDATE, Permission.BID_DECISION_MANAGE, Permission.PROFILE_MANAGE, Permission.ANALYTICS_VIEW, }, OrganizationRole.CONTRIBUTOR: { Permission.TENDER_VIEW, Permission.OPPORTUNITY_CREATE, Permission.OPPORTUNITY_UPDATE, Permission.ANALYTICS_VIEW, }, OrganizationRole.VIEWER: { Permission.TENDER_VIEW, Permission.ANALYTICS_VIEW, },}
Why Check Permissions Instead of Roles?
Bad:
if user.role == "administrator": allow()
Better:
if Permission.MEMBER_INVITE in permissions: allow()
Permission checks make future role changes easier.
You can create new roles without rewriting every route.
Password Hashing
Passwords must be transformed using a secure password-hashing algorithm.
Create:
app/security/password_hasher.py
Interface:
class PasswordHasher: def hash_password( self, password: str, ) -> str: ... def verify_password( self, plain_password: str, hashed_password: str, ) -> bool: ...
The authentication service should depend on this abstraction rather than a specific library implementation.
Password Validation
Recommended registration rules:
Minimum length: 12 charactersMaximum length: Reasonable application limitReject known weak passwordsAllow password managers and passphrasesDo not require arbitrary symbol patterns
Example Pydantic schema:
class UserRegistrationRequest(BaseModel): email: EmailStr password: str = Field( min_length=12, max_length=128, ) first_name: str = Field( min_length=1, max_length=100, ) last_name: str = Field( min_length=1, max_length=100, ) organization_name: str = Field( min_length=2, max_length=255, )
Registration Workflow
User submits registration | vValidate email and password | vCheck email uniqueness | vCreate user | vCreate organization | vCreate owner membership | vIssue tokens | vSend verification email
These database operations should run inside one transaction.
If organization creation fails, the user should not remain partially created.
Registration Service
Create:
app/services/registration_service.py
Example:
class RegistrationService: def __init__( self, user_repository, organization_repository, membership_repository, password_hasher, token_service, unit_of_work, ): self.user_repository = user_repository self.organization_repository = ( organization_repository ) self.membership_repository = ( membership_repository ) self.password_hasher = password_hasher self.token_service = token_service self.unit_of_work = unit_of_work
Registration method:
def register( self, data: UserRegistrationRequest,): existing_user = ( self.user_repository .get_by_email(data.email.lower()) ) if existing_user: raise EmailAlreadyRegisteredError() with self.unit_of_work.transaction(): user = self.user_repository.create( User( email=data.email.lower(), hashed_password=( self.password_hasher .hash_password(data.password) ), first_name=data.first_name, last_name=data.last_name, ) ) organization = ( self.organization_repository.create( Organization( name=data.organization_name, slug=self.generate_unique_slug( data.organization_name ), ) ) ) self.membership_repository.create( OrganizationMembership( organization_id=organization.id, user_id=user.id, role=OrganizationRole.OWNER, status=MembershipStatus.ACTIVE, joined_at=datetime.utcnow(), ) ) return self.token_service.issue_token_pair( user_id=user.id, organization_id=organization.id, )
Login Workflow
User submits email and password | vLoad user by email | vVerify password | vCheck account status | vLoad active memberships | vSelect active organization | vIssue token pair
Avoid revealing whether an email address exists.
Bad error:
No user exists with this email address.
Better:
Invalid email or password.
This reduces account-enumeration risk.
Authentication Service
Create:
app/services/authentication_service.py
def authenticate( self, email: str, password: str,): user = self.user_repository.get_by_email( email.lower() ) if not user: raise InvalidCredentialsError() if not self.password_hasher.verify_password( password, user.hashed_password, ): raise InvalidCredentialsError() if not user.is_active: raise AccountDisabledError() memberships = ( self.membership_repository .list_active_for_user(user.id) ) if not memberships: raise NoActiveOrganizationError() membership = memberships[0] self.user_repository.update_last_login( user.id ) return self.token_service.issue_token_pair( user_id=user.id, organization_id=( membership.organization_id ), membership_id=membership.id, role=membership.role, )
Access and Refresh Tokens
Use two token types:
Access tokenRefresh token
Access token:
- Short lifetime
- Used for API requests
- Contains limited identity claims
Refresh token:
- Longer lifetime
- Used only to obtain a new access token
- Should be revocable
- Should be rotated after use
Token Claims
An access token may contain:
{ "sub": "user:42", "organization_id": 10, "membership_id": 88, "role": "bid_manager", "token_type": "access", "exp": 1787000000}
Do not place sensitive personal or commercial data in token claims.
Token contents may be decoded by the client.
Token Service
Create:
app/security/token_service.py
Interface:
class TokenService: def create_access_token( self, *, user_id: int, organization_id: int, membership_id: int, role: OrganizationRole, ) -> str: ... def create_refresh_token( self, *, user_id: int, session_id: int, ) -> str: ... def decode_access_token( self, token: str, ): ...
Refresh Token Sessions
Refresh tokens should be associated with server-side session records.
Create:
auth_sessions
Recommended fields:
iduser_idrefresh_token_hashdevice_nameip_addressuser_agentexpires_atrevoked_atcreated_atlast_used_at
Store a hash of the refresh token rather than the raw token.
Authentication Session Model
class AuthenticationSession(Base): __tablename__ = "authentication_sessions" id: Mapped[int] = mapped_column( Integer, primary_key=True, ) user_id: Mapped[int] = mapped_column( ForeignKey( "users.id", ondelete="CASCADE", ), nullable=False, index=True, ) refresh_token_hash: Mapped[str] = mapped_column( String(255), nullable=False, unique=True, ) device_name: Mapped[str | None] = mapped_column( String(255), nullable=True, ) ip_address: Mapped[str | None] = mapped_column( String(64), nullable=True, ) user_agent: Mapped[str | None] = mapped_column( Text, nullable=True, ) expires_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, index=True, ) revoked_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) last_used_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False, )
Refresh Token Rotation
When a refresh token is used:
Validate token | vLoad session | vRevoke old refresh token | vCreate replacement token | vReturn new token pair
This reduces the damage caused by stolen refresh tokens.
A reused revoked refresh token may indicate token theft and should trigger session revocation.
Cookie Versus Browser Storage
For browser applications, refresh tokens should preferably be stored in secure cookies.
Recommended cookie properties:
HttpOnlySecureSameSiteRestricted path
The JavaScript application should not need direct access to the refresh token.
The access token may be kept in memory and renewed when required.
Avoid storing long-lived authentication secrets in ordinary browser storage.
Authentication API
Create:
app/api/routes/auth.py
Recommended endpoints:
POST /auth/registerPOST /auth/loginPOST /auth/refreshPOST /auth/logoutPOST /auth/logout-allGET /auth/me
Future endpoints:
POST /auth/verify-emailPOST /auth/forgot-passwordPOST /auth/reset-passwordPOST /auth/change-passwordGET /auth/sessionsDELETE /auth/sessions/{id}
Registration Endpoint
router.post( "/register", response_model=AuthenticationResponse,)def register( payload: UserRegistrationRequest, db: Session = Depends(get_db),): service = build_registration_service(db) return service.register(payload)
Login Endpoint
router.post( "/login", response_model=AuthenticationResponse,)def login( payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db),): service = build_authentication_service(db) token_pair = service.authenticate( email=payload.email, password=payload.password, ip_address=request.client.host, user_agent=request.headers.get( "user-agent" ), ) set_refresh_cookie( response, token_pair.refresh_token, ) return { "access_token": token_pair.access_token, "token_type": "bearer", "expires_in": token_pair.expires_in, }
Current User Endpoint
router.get( "/me", response_model=CurrentUserResponse,)def get_current_user_profile( context: AuthContext = Depends( get_auth_context ),): return context
Example response:
{ "user": { "id": 42, "email": "ben@example.com", "first_name": "Ben", "last_name": "Kemp" }, "organization": { "id": 10, "name": "Northstar Consulting", "slug": "northstar-consulting" }, "membership": { "role": "owner", "permissions": [ "tender:view", "opportunity:create", "member:invite" ] }}
Authentication Context
Create a normalized context object.
class AuthContext(BaseModel): user_id: int organization_id: int membership_id: int role: OrganizationRole permissions: set[Permission]
Protected routes can depend on this context.
FastAPI Authentication Dependency
Create:
app/api/dependencies/auth.py
def get_auth_context( credentials: HTTPAuthorizationCredentials = Depends( bearer_scheme ), db: Session = Depends(get_db),) -> AuthContext: claims = token_service.decode_access_token( credentials.credentials ) membership = ( membership_repository .get_active_membership( membership_id=claims.membership_id, user_id=claims.user_id, organization_id=claims.organization_id, ) ) if not membership: raise HTTPException( status_code=401, detail="Invalid authentication context", ) return AuthContext( user_id=membership.user_id, organization_id=( membership.organization_id ), membership_id=membership.id, role=membership.role, permissions=ROLE_PERMISSIONS[ membership.role ], )
Do not trust token role claims without checking that the membership still exists and remains active.
Permission Dependency
Create a reusable authorization dependency.
def require_permission( required_permission: Permission,): def dependency( context: AuthContext = Depends( get_auth_context ), ) -> AuthContext: if ( required_permission not in context.permissions ): raise HTTPException( status_code=403, detail="Insufficient permissions", ) return context return dependency
Usage:
router.post("/members/invite")def invite_member( payload: MemberInvitationCreate, context: AuthContext = Depends( require_permission( Permission.MEMBER_INVITE ) ),): ...
Authentication and Authorization Status Codes
Use status codes consistently.
401 UnauthorizedAuthentication is missing or invalid.403 ForbiddenAuthentication succeeded, but permission is insufficient.404 Not FoundThe resource does not exist within the user's tenant.
For tenant-isolated resources, returning 404 may be safer than confirming that another organization owns the resource.
Tenant-Level Query Filtering
Authentication alone is not enough.
Every organization-owned query must include the tenant boundary.
Bad:
select(SavedOpportunity).where( SavedOpportunity.id == opportunity_id)
Better:
select(SavedOpportunity).where( SavedOpportunity.id == opportunity_id, SavedOpportunity.organization_id == organization_id,)
Never load the record first and rely only on frontend filtering.
Adding Organization IDs to Existing Models
The following tables should be reviewed:
business_profilestender_matchessaved_opportunitiesnotificationsnotification_preferencescollaboration_integrationsnotification_deliveriesanalytics snapshotsbid decisionsinternal notes
Some globally shared tender records may remain organization-independent:
tenderstender_analysisprocurement_sources
Organization-specific data should reference the global tender through a foreign key.
Example:
Global tender | +---- Organization A match +---- Organization B match +---- Organization C match
Global and Tenant-Owned Data
A useful distinction is:
Global Data
Shared across the platform:
- Public tender source
- Tender title
- Tender description
- Public buyer
- Deadline
- Source URL
- Public AI extraction
Tenant-Owned Data
Private to the organization:
- Match score
- Business profile
- Saved status
- Internal notes
- Bid decision
- Assigned owner
- Pipeline stage
- Private AI analysis
- Collaboration configuration
This distinction reduces duplicated public data while preserving customer privacy.
Repository Tenant Scoping
Repositories should require organization_id.
Bad method:
def get_by_id( self, opportunity_id: int,): ...
Better:
def get_by_id( self, *, opportunity_id: int, organization_id: int,): statement = ( select(SavedOpportunity) .where( SavedOpportunity.id == opportunity_id, SavedOpportunity.organization_id == organization_id, ) ) return self.db.scalar(statement)
Making the organization ID mandatory reduces accidental cross-tenant access.
Invitation Workflow
Organization owners and administrators need to invite team members.
Workflow:
Administrator enters email | vSelect role | vCreate invitation | vSend invitation email | vRecipient accepts | vCreate or link user | vActivate membership
Invitation Model
Create:
app/models/organization_invitation.py
Fields:
idorganization_idemailroletoken_hashstatusinvited_by_user_idexpires_ataccepted_atcreated_at
Invitation statuses:
class InvitationStatus(str, Enum): PENDING = "pending" ACCEPTED = "accepted" EXPIRED = "expired" REVOKED = "revoked"
Invitation SQLAlchemy Model
class OrganizationInvitation(Base): __tablename__ = "organization_invitations" id: Mapped[int] = mapped_column( Integer, primary_key=True, ) organization_id: Mapped[int] = mapped_column( ForeignKey( "organizations.id", ondelete="CASCADE", ), nullable=False, index=True, ) email: Mapped[str] = mapped_column( String(320), nullable=False, index=True, ) role: Mapped[OrganizationRole] = mapped_column( SqlEnum( OrganizationRole, name="invitation_organization_role", ), nullable=False, ) token_hash: Mapped[str] = mapped_column( String(255), unique=True, nullable=False, ) status: Mapped[InvitationStatus] = mapped_column( SqlEnum( InvitationStatus, name="invitation_status", ), default=InvitationStatus.PENDING, nullable=False, index=True, ) invited_by_user_id: Mapped[int] = mapped_column( ForeignKey( "users.id", ondelete="CASCADE", ), nullable=False, ) expires_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, index=True, ) accepted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, nullable=False, )
Invitation Security
The invitation email contains a random token.
Store only a hash of that token in the database.
Workflow:
Generate random token | vStore token hash | vSend raw token in invitation URL | vHash submitted token | vCompare with stored hash
Invitation URLs should expire.
Example:
https://app.example.com/invitations/accept?token=...
Invitation API
Recommended endpoints:
POST /organizations/current/invitationsGET /organizations/current/invitationsDELETE /organizations/current/invitations/{id}POST /invitations/accept
Accepting an Invitation
There are two scenarios.
Existing User
User logs in | vAccept invitation | vCreate organization membership
New User
Open invitation link | vCreate account and password | vVerify invitation | vCreate membership
The invitation email must match the user’s account email unless an explicit administrative exception is supported.
Preventing Duplicate Memberships
Before accepting:
existing_membership = ( membership_repository .get_by_user_and_organization( user_id=user.id, organization_id=invitation.organization_id, ))if existing_membership: raise MembershipAlreadyExistsError()
The database unique constraint provides the final protection.
Member Management API
Recommended endpoints:
GET /organizations/current/membersPATCH /organizations/current/members/{id}DELETE /organizations/current/members/{id}
Supported operations:
- Change role
- Suspend membership
- Reactivate membership
- Remove member
Protecting the Last Owner
An organization must always have at least one active owner.
Prevent:
- Removing the last owner
- Downgrading the last owner
- Suspending the last owner
Example service validation:
if membership.role == OrganizationRole.OWNER: active_owner_count = ( membership_repository .count_active_owners( organization_id ) ) if active_owner_count <= 1: raise LastOwnerProtectionError()
Switching Organizations
A user may belong to multiple organizations.
Add endpoint:
POST /auth/switch-organization
Request:
{ "organization_id": 15}
The backend must verify an active membership before issuing a replacement access token.
Organization Selection Flow
User logs in | vOne active organization? | +---- Yes: continue | +---- No: | v Show organization selector
The selected organization becomes part of the authentication context.
Audit Logging
Security-sensitive actions should be recorded.
Examples:
User registeredUser logged inLogin failedPassword changedRefresh token rotatedUser logged outInvitation createdInvitation acceptedMembership role changedMember removedIntegration credentials changedOrganization settings updated
Create:
audit_events
Recommended fields:
idorganization_idactor_user_idevent_typetarget_typetarget_idmetadataip_addresscreated_at
Do not store passwords, tokens, or raw secrets in audit metadata.
Rate Limiting Authentication Endpoints
Protect:
/auth/login/auth/register/auth/forgot-password/auth/refresh/invitations/accept
Rate limits may be applied by:
- IP address
- Email address
- Session
- Account
- Organization
Repeated failed login attempts should generate security logs.
Avoid permanent account lockouts that can be abused for denial-of-service attacks.
Email Verification
New accounts should verify their email address.
Workflow:
Create account | vGenerate verification token | vSend email | vUser opens verification link | vMark email verified
Some actions may remain restricted until verification:
- Inviting members
- Creating external integrations
- Starting a paid subscription
- Exporting data
Password Reset
Password reset workflow:
User requests reset | vAlways return generic response | vGenerate short-lived reset token | vSend reset email when account exists | vValidate token | vSet new password | vRevoke active sessions
Generic response:
If an account exists for that email address, reset instructions have been sent.
Session Revocation
Users should be able to:
- Log out of the current device
- Log out of all devices
- Revoke a specific session
- Review recent sessions
Example session page:
Current SessionsChrome on WindowsThe Hague, NetherlandsActive nowMobile SafariLast active yesterday[Revoke]
Location should be treated as approximate and optional.
React Frontend Structure
Create:
src/├── auth/│ ├── AuthContext.jsx│ ├── AuthProvider.jsx│ ├── ProtectedRoute.jsx│ ├── PermissionGate.jsx│ └── tokenManager.js│├── pages/│ ├── LoginPage.jsx│ ├── RegisterPage.jsx│ ├── AcceptInvitationPage.jsx│ ├── OrganizationMembersPage.jsx│ ├── OrganizationSettingsPage.jsx│ └── SessionManagementPage.jsx│├── components/│ └── Auth/│ ├── LoginForm.jsx│ ├── RegistrationForm.jsx│ ├── OrganizationSelector.jsx│ ├── InviteMemberForm.jsx│ ├── MemberTable.jsx│ ├── RoleSelector.jsx│ └── AccessDenied.jsx│├── hooks/│ ├── useAuth.js│ ├── usePermissions.js│ └── useOrganizationMembers.js│└── services/ ├── authService.js └── organizationService.js
Authentication Context
The React authentication context manages:
Current userCurrent organizationCurrent rolePermissionsAccess tokenLoading stateLoginLogoutToken refreshOrganization switching
Example state:
const [authState, setAuthState] = useState({ user: null, organization: null, role: null, permissions: [], accessToken: null, loading: true,});
Initial Authentication Check
When the application loads:
Load application | vRequest new access token using secure refresh cookie | +---- Success: | Load current user | +---- Failure: Show login page
The user should not briefly see protected content before authentication is confirmed.
Authentication Service
Create:
src/services/authService.js
Login:
export const login = async credentials => { const response = await api.post( "/auth/login", credentials, { withCredentials: true, } ); return response.data;};
Refresh:
export const refreshSession = async () => { const response = await api.post( "/auth/refresh", {}, { withCredentials: true, } ); return response.data;};
Logout:
export const logout = async () => { await api.post( "/auth/logout", {}, { withCredentials: true, } );};
API Client Interceptor
Attach the access token to API requests.
api.interceptors.request.use(config => { const token = tokenManager.getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config;});
When an access token expires:
Request receives 401 | vAttempt one token refresh | +---- Success: | Retry original request | +---- Failure: Clear authentication state Redirect to login
Prevent multiple simultaneous refresh requests from creating a refresh storm.
Protected Route
Create:
src/auth/ProtectedRoute.jsx
export function ProtectedRoute({ children,}) { const { user, loading, } = useAuth(); if (loading) { return <AuthenticationLoading />; } if (!user) { return <Navigate to="/login" replace />; } return children;}
Permission Gate
Create:
src/auth/PermissionGate.jsx
export function PermissionGate({ permission, children, fallback = null,}) { const { permissions } = useAuth(); if (!permissions.includes(permission)) { return fallback; } return children;}
Usage:
<PermissionGate permission="member:invite"> <InviteMemberButton /></PermissionGate>
Frontend permission checks improve usability, but the backend must always enforce the same permission independently.
Login Page
The login form should contain:
Email addressPasswordRemember this deviceForgot passwordSign in buttonRegistration link
Do not display detailed technical authentication errors.
Example:
Unable to sign in. Check your email and password and try again.
Registration Page
Fields:
First nameLast nameWork emailPasswordOrganization nameTerms acceptance
After registration:
Create account | vCreate organization | vSign user in | vOpen onboarding flow
Organization Members Page
Example:
---------------------------------------------------------Team Members---------------------------------------------------------Ben Kempben@example.comOwnerActiveAnna de Vriesanna@example.comBid ManagerActiveMark Jansenmark@example.comContributorInvited[Invite Member]
Actions depend on permissions.
Invite Member Form
Fields:
Email addressRoleOptional personal message
Example:
<form onSubmit={handleInvite}> <input type="email" name="email" placeholder="colleague@example.com" /> <RoleSelector value={role} onChange={setRole} /> <button type="submit"> Send Invitation </button></form>
Role Selector
Role descriptions should explain practical access.
Example:
AdministratorCan manage users, profiles, integrations, and opportunities.Bid ManagerCan manage bid decisions, profiles, and pipeline activity.ContributorCan review and update opportunities.ViewerCan view tenders and analytics without changing data.
Users should not need to interpret internal permission codes.
Organization Settings
Recommended sections:
Organization profileMembersRoles and accessNotification settingsIntegrationsSecuritySessionsBillingData retention
Initially, this article focuses on profile, membership, and access control.
Hiding Versus Disabling Actions
When a user lacks permission, decide whether to:
- Hide the action
- Display it disabled
- Display it with an explanation
Example:
Delete OrganizationOnly organization owners may perform this action.
For sensitive administrative actions, hiding may be appropriate.
For discoverability, disabled controls with explanations can be more helpful.
Alembic Migration
Generate:
alembic revision --autogenerate -m "add authentication and organizations"
Review carefully.
The migration should create:
organizationsusersorganization_membershipsorganization_invitationsauthentication_sessionsaudit_events
It may also add:
organization_id
to existing tenant-owned tables.
Apply:
alembic upgrade head
Migrating Existing Prototype Data
The application may already contain records without organizations.
Create a safe migration strategy:
1. Create default organization2. Create initial owner user3. Assign existing private records4. Make organization_id non-null5. Add tenant indexes and constraints
Do not immediately add a non-null organization field without assigning existing rows.
Tenant Indexes
Common indexes include:
organization_idorganization_id + statusorganization_id + created_atorganization_id + tender_idorganization_id + pipeline_stage
Example:
Index( "ix_saved_opportunities_org_stage", "organization_id", "pipeline_stage",)
These improve organization-scoped queries.
Unique Constraints in Multi-Tenant Tables
A globally unique field may become organization-specific.
Before:
tender_id must be unique
After:
organization_id + tender_id must be unique
Example:
UniqueConstraint( "organization_id", "tender_id", name="uq_saved_opportunity_org_tender",)
Different organizations may save the same public tender independently.
Testing Authentication
Create:
tests/auth/├── test_registration.py├── test_login.py├── test_refresh_tokens.py├── test_logout.py├── test_permissions.py├── test_invitations.py├── test_memberships.py└── test_tenant_isolation.py
Registration Tests
Test:
- New user registration
- Organization creation
- Owner membership creation
- Password hashing
- Duplicate email rejection
- Duplicate organization slug handling
- Transaction rollback
- Token issuance
- Verification email queueing
Login Tests
Test:
- Valid credentials
- Incorrect password
- Unknown email
- Disabled account
- No active membership
- Suspended organization
- Last-login update
- Session creation
Ensure invalid email and invalid password produce equivalent public responses.
Refresh Token Tests
Test:
- Valid refresh
- Expired refresh token
- Revoked session
- Token rotation
- Reuse of old token
- Logout revocation
- Logout-all behavior
Permission Tests
Example:
def test_viewer_cannot_invite_member( client, viewer_access_token,): response = client.post( "/organizations/current/invitations", headers={ "Authorization": f"Bearer {viewer_access_token}" }, json={ "email": "new@example.com", "role": "contributor", }, ) assert response.status_code == 403
Tenant Isolation Tests
This is one of the most important test categories.
Example:
def test_user_cannot_read_other_organization_opportunity( client, organization_a_token, organization_b_opportunity,): response = client.get( ( "/saved-opportunities/" f"{organization_b_opportunity.id}" ), headers={ "Authorization": f"Bearer {organization_a_token}" }, ) assert response.status_code == 404
Repeat this pattern for:
- Business profiles
- Saved opportunities
- Notifications
- Analytics
- Integrations
- Members
- Invitations
- Internal notes
Invitation Tests
Test:
- Administrator can invite
- Contributor cannot invite
- Invitation token is hashed
- Expired invitation is rejected
- Revoked invitation is rejected
- Wrong email cannot accept
- Duplicate membership is prevented
- Invitation creates correct role
- Invitation may be accepted only once
Last Owner Tests
Test:
Last owner cannot be removedLast owner cannot be downgradedLast owner cannot be suspendedOwner can transfer ownershipAdditional owner can be removed safely
Frontend Tests
Test:
- Login form validation
- Registration form validation
- Loading authentication state
- Protected route redirection
- Access-token refresh
- Logout
- Permission-controlled buttons
- Member list rendering
- Invitation submission
- Role updates
- Access denied states
- Organization switching
End-to-End Authentication Scenario
A complete scenario:
1. Register a new account2. Create an organization automatically3. Verify owner membership4. Open the tender dashboard5. Invite a bid manager6. Accept the invitation7. Log in as the bid manager8. Save and update an opportunity9. Confirm the bid manager cannot manage integrations10. Log in as a viewer11. Confirm the viewer can read but not edit12. Create a second organization13. Confirm data remains isolated14. Log out15. Confirm protected routes are inaccessible
Security Checklist
At this stage, verify:
✔ Passwords are hashed✔ Authentication errors are generic✔ Access tokens have short lifetimes✔ Refresh tokens are rotated✔ Refresh tokens can be revoked✔ Refresh tokens are not stored in plain text✔ Protected routes require authentication✔ Permissions are enforced by the backend✔ Organization IDs scope private queries✔ Cross-tenant resources return no data✔ Invitation tokens expire✔ Sensitive actions create audit events✔ Authentication endpoints are rate-limited✔ Secrets are excluded from logs✔ The final owner cannot be removed✔ Sessions can be revoked
Current Architecture
External Tender Sources | vTender Ingestion | vAI Analysis | vPersonalized Matching | vOrganization-Scoped Workspace | +---- Recommendation Dashboard +---- Saved Opportunities +---- Analytics +---- Notifications +---- Email +---- Slack and Teams | vAuthentication and Authorization | +---- Users +---- Organizations +---- Memberships +---- Roles +---- Permissions +---- Sessions +---- Invitations
The platform now understands both who the user is and which organizational data the user may access.
Current Deliverables
At this stage, we have designed:
✔ User accounts✔ Organization accounts✔ Organization memberships✔ Role definitions✔ Permission matrix✔ Password hashing✔ Registration workflow✔ Login workflow✔ Access tokens✔ Refresh-token sessions✔ Token rotation✔ Logout and session revocation✔ Protected FastAPI dependencies✔ Permission-based authorization✔ Organization-scoped repositories✔ Tenant-level query filtering✔ Team invitation workflow✔ Member management✔ Last-owner protection✔ Organization switching✔ Audit logging✔ React authentication context✔ Protected frontend routes✔ Permission-aware components✔ Organization member interface✔ Authentication tests✔ Authorization tests✔ Tenant-isolation tests
The AI Tender Opportunity Scanner now has the identity and access foundation required for a secure multi-user SaaS platform.
Recommended Git Commit
git add .git commit -m "Build authentication organizations and role based access control"
Push the changes:
git push origin main
What We Will Build Next
Authentication establishes the tenant boundary, but every existing service and repository must now apply that boundary consistently.
The next stage will strengthen multi-tenant architecture by introducing:
- Organization-scoped database access
- Tenant-aware service methods
- Composite indexes
- Data-isolation tests
- Tenant-aware background jobs
- Organization-specific AI usage tracking
- Secure storage separation
- Tenant lifecycle management
The next article in the development series is:
“Building Secure Multi-Tenant Data Isolation for the Tender SaaS Platform.”
Conclusion
Authentication, organization accounts, and role-based access control transform the AI Tender Opportunity Scanner from a single-user prototype into a secure collaborative SaaS application.
The authentication layer verifies user identity through secure password hashing, access tokens, refresh-token rotation, session revocation, and protected FastAPI dependencies. Organization memberships and permission-based authorization determine which actions each user may perform.
The organization model also creates the primary tenant boundary for business profiles, saved opportunities, analytics, notifications, and collaboration integrations. Every private query must now be scoped to the authenticated organization.
With users, organizations, invitations, roles, permissions, and sessions in place, the next step is applying rigorous tenant isolation across the entire database and service architecture.