Series: Building Quorentra CRM from Zero — A Modular, ChatGPT-Native AI CRM
Building secure login, JWT access tokens, bearer authentication, current-user resolution, protected endpoints, token validation, and authentication tests.

1. Introduction
In Part 7, Quorentra gained its first complete end-user workflow.
A new user can now send:
POST /api/v1/auth/register
and Quorentra atomically creates:
User +Organization +Owner Membership
The user exists.
The password is securely hashed.
The organization exists.
The user owns that organization.
But there is still a major limitation.
Quorentra does not recognize the user on subsequent requests.
That changes in Part 8.
We are going to implement:
POST /api/v1/auth/login
which will:
Email + Password │ ▼Credential Verification │ ▼JWT Access Token
That token can then be presented with future requests:
Authorization: Bearer <token>
Quorentra will validate the token, resolve the user, and establish:
Current User
By the end of this article, Quorentra will reach:
Quorentra 0.0.6 — Authenticated User Identity
2. What Authentication Solves
Registration answers:
Who wants to create an account?
Authentication answers:
Who is making this request?
These are different concerns.
After successful registration, the database may contain:
User├── id├── email├── password_hash├── display_name└── is_active
But an HTTP request arriving later contains no automatic connection to that record.
For example:
GET /api/v1/auth/me
Quorentra must determine which user is making that request.
JWT authentication gives us that bridge.
3. Authentication Versus Authorization
Before implementing anything, we need to keep two concepts separate.
Authentication answers:
Who are you?
Authorization answers:
What are you allowed to do?
Part 8 implements authentication.
It does not yet implement complete authorization.
The progression is:
Credentials ↓Authentication ↓Current User ↓Tenant Context ↓Membership ↓Authorization
Part 8 stops at:
Current User
Part 9 will continue into tenant context.
4. Why JWT for the MVP?
There are several ways to maintain authenticated identity:
- server-side sessions;
- opaque bearer tokens;
- JWT access tokens;
- external identity providers;
- OAuth/OIDC;
- enterprise SSO.
For the Quorentra MVP, JWT access tokens provide a practical starting point.
The request flow becomes:
Login │ ▼JWT │ ▼Client stores token │ ▼Authorization: Bearer <JWT> │ ▼FastAPI │ ▼JWT validation │ ▼Current User
This works well for our modular API architecture.
5. JWT Is Not Encryption
This distinction is important.
A JWT is normally signed, not encrypted.
Its payload can be decoded.
Therefore we must never put secrets inside it.
Do not include:
passwordpassword_hashAPI keysdatabase credentialsprivate customer information
The token should contain only the minimum claims required to establish authenticated identity.
For our MVP, that primarily means:
subiatexp
where:
sub = user identifieriat = issued-at timeexp = expiration time
6. Starting Checkpoint
Part 8 assumes Part 7 is complete.
The authentication module should resemble:
app/└── modules/ └── auth/ ├── __init__.py ├── exceptions.py ├── router.py ├── schemas.py └── service.py
The user model should include:
emailpassword_hashis_active
Password hashing should already support:
hash_password(...)verify_password(...)
The registration endpoint should work:
POST /api/v1/auth/register
Run:
python -m pytest
before continuing.
All existing tests should pass.
7. Install JWT Support
We need a library for JWT creation and validation.
For this MVP, install PyJWT:
python -m pip install PyJWT
Then verify:
python -m pip show PyJWT
Add:
PyJWT
to:
requirements.txt
Our relevant authentication dependencies now include:
pwdlib[argon2]email-validatorPyJWT
8. JWT Signing Requires a Secret
JWT access tokens need to be cryptographically signed.
For the initial architecture we will use:
HS256
which uses a shared secret.
Conceptually:
JWT Header +JWT Payload +Secret Key ↓Signature
When the token returns:
HeaderPayloadSignature │ ▼Verify using secret
If someone modifies the token payload, the signature validation fails.
9. Never Hard-Code the JWT Secret
Do not write:
JWT_SECRET = "quorentra-secret"
inside the source code.
The signing secret is configuration.
It belongs in the environment.
Open:
.env
and add:
JWT_SECRET_KEY=<your-development-secret>JWT_ALGORITHM=HS256ACCESS_TOKEN_EXPIRE_MINUTES=30
Do not commit the real .env file.
Your repository should already exclude it through:
.gitignore
10. Generate a Strong Development Secret
Python can generate a cryptographically strong random value.
Run:
python -c "import secrets; print(secrets.token_urlsafe(64))"
Copy the generated value into:
JWT_SECRET_KEY=
For example, your .env should conceptually contain:
JWT_SECRET_KEY=<generated-secret>JWT_ALGORITHM=HS256ACCESS_TOKEN_EXPIRE_MINUTES=30
Do not copy example secrets from tutorials into production systems.
Each deployment should have its own secret.
11. Update the Application Settings
Open:
app/core/config.py
Add the JWT settings to your existing settings model.
Conceptually:
class Settings(BaseSettings): ... jwt_secret_key: str jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 30
If your project uses:
SettingsConfigDict( env_file=".env", ...)
Pydantic Settings will load:
JWT_SECRET_KEYJWT_ALGORITHMACCESS_TOKEN_EXPIRE_MINUTES
from the environment.
12. Validate Configuration at Startup
A missing JWT secret should not result in mysterious failures only when the first user tries to log in.
Configuration problems should fail early.
The application should require:
JWT_SECRET_KEY
to be present.
If it is missing, application startup should clearly report the configuration problem.
This follows an important operational principle:
Invalid security configuration should fail fast.
13. Extend the Security Module
Open:
app/core/security.py
It currently handles password hashing.
We will extend it to handle access tokens.
Add:
from datetime import datetime, timedelta, timezonefrom uuid import UUIDimport jwtfrom app.core.config import get_settings
Then define:
def create_access_token( user_id: UUID,) -> str: settings = get_settings() now = datetime.now(timezone.utc) expires_at = now + timedelta( minutes=settings.access_token_expire_minutes ) payload = { "sub": str(user_id), "iat": now, "exp": expires_at, } return jwt.encode( payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm, )
We can now issue signed access tokens.
14. Why sub Contains the User ID
JWT defines the standard:
sub
claim as the subject of the token.
For Quorentra, the subject is the authenticated user.
Therefore:
sub = user.id
For example:
{ "sub": "760a672a-fd70-4eb2-a250-15ca1ecf9d41"}
This gives us a stable identifier for current-user resolution.
We deliberately do not use:
email
as the primary identity claim.
Emails can change.
Database user IDs should remain stable.
15. Why We Do Not Put Organization ID in the Token Yet
It may be tempting to issue:
{ "sub": "...", "organization_id": "...", "role": "owner"}
But we are deliberately not doing that yet.
A user may eventually belong to multiple organizations:
User │ ├── Organization A ├── Organization B └── Organization C
Authentication establishes the user.
Tenant context establishes the active organization.
Those are separate concepts.
Therefore Part 8 tokens identify:
User
not:
User + Organization + Role
This keeps the authentication architecture clean.
16. Token Expiration
Our access tokens expire after:
30 minutes
by default.
Why expire access tokens?
Because a stolen bearer token can otherwise remain usable indefinitely.
The exp claim limits that exposure.
Conceptually:
Login ↓Token issued ↓30-minute lifetime ↓Expired
An expired token must no longer authenticate requests.
17. Create Token Decoding
Add to:
app/core/security.py
the following:
def decode_access_token( token: str,) -> dict: settings = get_settings() return jwt.decode( token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm], )
PyJWT will verify:
- token signature;
- expiration;
- token structure.
If validation fails, it raises an exception.
We will translate that failure at the authentication boundary.
18. Add Authentication Exceptions
Open:
app/modules/auth/exceptions.py
We already have registration exceptions.
Add:
class AuthenticationError(Exception): passclass InvalidCredentialsError( AuthenticationError): passclass InactiveUserError( AuthenticationError): passclass InvalidTokenError( AuthenticationError): pass
Again, these are application/authentication concepts.
They are not HTTP exceptions.
19. Why Login Errors Should Be Generic
Suppose a login request uses an unknown email.
We should not return:
No account exists with that email.
Then suppose the email exists but the password is wrong.
We should not return:
Incorrect password.
Those responses reveal whether an account exists.
Instead, both should become:
Invalid email or password.
This reduces account-enumeration information leakage.
20. Create the Login Request Schema
Open:
app/modules/auth/schemas.py
Add:
class LoginRequest(BaseModel): email: EmailStr password: str = Field( min_length=1, max_length=128, )
Notice that login does not enforce the registration minimum of 12 characters.
Why?
Because login should verify the supplied credential against the stored hash.
Password-policy enforcement belongs primarily to password creation/change operations.
21. Create the Login Response Schema
Add:
class LoginResponse(BaseModel): access_token: str token_type: str = "bearer"
A successful login will return:
{ "access_token": "<jwt>", "token_type": "bearer"}
Later we may add:
expires_inrefresh_token
but they are not required for the current slice.
22. Build the Login Service
Open:
app/modules/auth/service.py
Import:
from app.core.security import ( create_access_token, hash_password, verify_password,)
Also import:
from app.modules.auth.exceptions import ( EmailAlreadyRegisteredError, InactiveUserError, InvalidCredentialsError,)
and:
from app.modules.auth.schemas import ( LoginRequest, LoginResponse, RegisterRequest, RegisterResponse,)
Now add the login method.
23. Implement Credential Verification
Inside AuthService:
def login( self, request: LoginRequest,) -> LoginResponse: email = normalize_email(str(request.email)) user = self.users.get_by_email(email) if user is None: raise InvalidCredentialsError( "Invalid email or password." ) if not verify_password( request.password, user.password_hash, ): raise InvalidCredentialsError( "Invalid email or password." ) if not user.is_active: raise InactiveUserError( "This user account is inactive." ) access_token = create_access_token( user.id ) return LoginResponse( access_token=access_token, )
Quorentra can now authenticate credentials.
24. The Login Execution Path
The service implements:
LoginRequest │ ▼Normalize Email │ ▼UserRepository.get_by_email() │ ├── Missing ──────────► Invalid credentials │ ▼Verify Password │ ├── Invalid ──────────► Invalid credentials │ ▼Check is_active │ ├── False ────────────► Inactive user │ ▼Create JWT │ ▼LoginResponse
No database mutation occurs.
Login is primarily a read-and-verify operation.
25. Add the Login Endpoint
Open:
app/modules/auth/router.py
Import the new schemas and exceptions.
Then add:
router.post( "/login", response_model=LoginResponse,)def login( request: LoginRequest, db: Session = Depends(get_db),) -> LoginResponse: service = AuthService(db) try: return service.login(request) except InvalidCredentialsError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc), headers={ "WWW-Authenticate": "Bearer", }, ) from exc except InactiveUserError as exc: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(exc), ) from exc
The endpoint becomes:
POST /api/v1/auth/login
26. Why Invalid Credentials Return 401
HTTP:
401 Unauthorized
really means the request lacks valid authentication credentials.
That is appropriate for:
wrong emailwrong passwordinvalid tokenexpired token
We also return:
WWW-Authenticate: Bearer
which is the standard authentication challenge header for bearer-token APIs.
27. Why an Inactive User Returns 403
An inactive account may have provided correct credentials.
Authentication information is valid, but the account is not allowed to continue.
Therefore:
403 Forbidden
is a reasonable distinction for the MVP.
Later account-state modeling may become more sophisticated.
For example:
activesuspendedlockedpending_verificationdisabled
We do not need that complexity yet.
28. Test Login in Swagger
Start:
python -m uvicorn app.main:app --reload
Open:
http://127.0.0.1:8000/docs
Use the account created in Part 7.
Send:
{ "email": "owner@example.com", "password": "VerySecurePassword123!"}
to:
POST /api/v1/auth/login
Expected:
200 OK
with:
{ "access_token": "<long-jwt-token>", "token_type": "bearer"}
Quorentra can now authenticate a user.
29. Understanding the JWT Structure
A JWT typically looks like:
xxxxx.yyyyy.zzzzz
It contains three encoded sections:
Header .Payload .Signature
Conceptually:
Header{ "alg": "HS256", "typ": "JWT"}Payload{ "sub": "<user-id>", "iat": ..., "exp": ...}Signature<cryptographic signature>
Again, the payload is not secret.
The signature provides integrity and authenticity.
30. Authentication Is Still Incomplete
We can issue a token.
But protected endpoints still need a reusable mechanism to:
Extract token ↓Validate token ↓Read user ID ↓Load user ↓Return current user
That mechanism should be centralized.
We do not want every endpoint manually parsing bearer tokens.
FastAPI dependencies are ideal for this.
31. Create the Authentication Dependencies
Create:
app/modules/auth/dependencies.py
Run:
New-Item app\modules\auth\dependencies.py -ItemType File
Add:
from uuid import UUIDimport jwtfrom fastapi import Depends, HTTPException, statusfrom fastapi.security import ( HTTPAuthorizationCredentials, HTTPBearer,)from sqlalchemy.orm import Sessionfrom app.core.security import decode_access_tokenfrom app.db.dependencies import get_dbfrom app.modules.users.model import Userfrom app.modules.users.repository import UserRepository
Now define the bearer scheme.
32. Configure HTTP Bearer Authentication
Add:
bearer_scheme = HTTPBearer( auto_error=False,)
This tells FastAPI to inspect:
Authorization: Bearer <token>
but lets us control the error response ourselves.
That gives us consistent authentication behavior.
33. Build get_current_user
Add:
def get_current_user( credentials: HTTPAuthorizationCredentials | None = Depends( bearer_scheme ), db: Session = Depends(get_db),) -> User: authentication_error = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials.", headers={ "WWW-Authenticate": "Bearer", }, ) if credentials is None: raise authentication_error token = credentials.credentials try: payload = decode_access_token(token) subject = payload.get("sub") if subject is None: raise authentication_error user_id = UUID(subject) except ( jwt.InvalidTokenError, ValueError, ) as exc: raise authentication_error from exc users = UserRepository(db) user = users.get_by_id(user_id) if user is None: raise authentication_error if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive.", ) return user
We now have reusable authenticated-user resolution.
34. What get_current_user Actually Does
The dependency implements:
HTTP Request │ ▼Authorization Header │ ▼Bearer Token │ ▼JWT Signature Validation │ ▼Expiration Validation │ ▼sub Claim │ ▼UUID Validation │ ▼UserRepository │ ▼User Exists? │ ▼User Active? │ ▼Current User
Any protected route can now depend on:
Depends(get_current_user)
35. Why We Load the User from PostgreSQL
We could simply trust the token payload.
For example:
sub = user ID
and assume that means the user is still valid.
But a user might have been:
disableddeletedsuspended
after the token was issued.
Loading the user from PostgreSQL allows Quorentra to evaluate current account state.
For the MVP, that means checking:
is_active
on every protected request.
36. Create the Current User Response
Add to:
app/modules/auth/schemas.py
the following:
class CurrentUserResponse(BaseModel): id: UUID email: EmailStr display_name: str | None is_active: bool
Again, we deliberately exclude:
password_hash
37. Create the First Protected Endpoint
Open:
app/modules/auth/router.py
Import:
from app.modules.auth.dependencies import ( get_current_user,)from app.modules.users.model import User
Then add:
router.get( "/me", response_model=CurrentUserResponse,)def get_me( current_user: User = Depends(get_current_user),) -> CurrentUserResponse: return CurrentUserResponse( id=current_user.id, email=current_user.email, display_name=current_user.display_name, is_active=current_user.is_active, )
The endpoint becomes:
GET /api/v1/auth/me
This is Quorentra’s first protected API endpoint.
38. Test /auth/me Without a Token
Send:
GET /api/v1/auth/me
without an authorization header.
Expected:
401 Unauthorized
The endpoint is protected.
39. Test /auth/me With the Token
First log in:
POST /api/v1/auth/login
Copy:
access_token
Then send:
GET /api/v1/auth/me
with:
Authorization: Bearer <access_token>
Expected:
{ "id": "...", "email": "owner@example.com", "display_name": "CRM Owner", "is_active": true}
The full authentication path now works.
40. The Complete Authentication Flow
We now have:
Registration │ ▼User + Password Hash │ ▼Login │ ▼Password Verification │ ▼JWT │ ▼Bearer Header │ ▼JWT Validation │ ▼User Lookup │ ▼Current User
This is the first complete authenticated request lifecycle in Quorentra.
41. Test an Invalid Password
Send:
{ "email": "owner@example.com", "password": "WrongPassword"}
Expected:
401 Unauthorized
with:
{ "detail": "Invalid email or password."}
The response should not reveal whether the email exists.
42. Test an Unknown Email
Send:
{ "email": "unknown@example.com", "password": "SomePassword"}
Expected:
401 Unauthorized
with exactly the same general message:
{ "detail": "Invalid email or password."}
This keeps the authentication boundary intentionally non-specific.
43. Test an Invalid Token
Call:
GET /api/v1/auth/me
with:
Authorization: Bearer this-is-not-a-valid-token
Expected:
401 Unauthorized
The request must never reach protected business logic.
44. Test a Modified Token
Take a valid JWT and alter one character.
Then call:
GET /api/v1/auth/me
The signature should no longer validate.
Expected:
401 Unauthorized
This demonstrates why signing matters.
A client cannot safely modify:
subexp
or other claims without invalidating the signature.
45. Expired Token Behavior
PyJWT validates:
exp
during decoding.
An expired token will therefore raise a token validation error.
Our dependency translates that into:
401 Unauthorized
This gives us the desired lifecycle:
Valid Token ↓AuthenticatedExpired Token ↓401
46. Add Login API Tests
Create:
tests/api/test_login.py
Run:
New-Item tests\api\test_login.py -ItemType File
Start with a helper that registers a unique user.
For example:
from uuid import uuid4from fastapi.testclient import TestClientfrom app.main import appclient = TestClient(app)def create_test_user() -> tuple[str, str]: unique_value = uuid4().hex email = f"login-{unique_value}@example.com" password = "VerySecurePassword123!" response = client.post( "/api/v1/auth/register", json={ "email": email, "password": password, "display_name": "Login Test User", "organization_name": "Login Test Organization", }, ) assert response.status_code == 201 return email, password
47. Test Successful Login
Add:
def test_login_success() -> None: email, password = create_test_user() response = client.post( "/api/v1/auth/login", json={ "email": email, "password": password, }, ) assert response.status_code == 200 data = response.json() assert data["access_token"] assert data["token_type"] == "bearer"
This verifies token issuance through the HTTP boundary.
48. Test Invalid Password
Add:
def test_login_invalid_password() -> None: email, _ = create_test_user() response = client.post( "/api/v1/auth/login", json={ "email": email, "password": "WrongPassword", }, ) assert response.status_code == 401 assert response.json()["detail"] == ( "Invalid email or password." )
49. Test Unknown User
Add:
def test_login_unknown_user() -> None: unique_value = uuid4().hex response = client.post( "/api/v1/auth/login", json={ "email": ( f"unknown-{unique_value}@example.com" ), "password": "SomePassword", }, ) assert response.status_code == 401 assert response.json()["detail"] == ( "Invalid email or password." )
The unknown-user and wrong-password responses remain intentionally identical.
50. Test the Protected Endpoint
Add:
def test_get_current_user() -> None: email, password = create_test_user() login_response = client.post( "/api/v1/auth/login", json={ "email": email, "password": password, }, ) token = login_response.json()["access_token"] response = client.get( "/api/v1/auth/me", headers={ "Authorization": f"Bearer {token}", }, ) assert response.status_code == 200 data = response.json() assert data["email"] == email assert data["is_active"] is True
This is our first end-to-end authenticated API test.
51. Test Missing Authentication
Add:
def test_get_current_user_without_token() -> None: response = client.get( "/api/v1/auth/me" ) assert response.status_code == 401
A protected endpoint must never silently become public.
52. Test Invalid Bearer Token
Add:
def test_get_current_user_invalid_token() -> None: response = client.get( "/api/v1/auth/me", headers={ "Authorization": "Bearer invalid-token", }, ) assert response.status_code == 401
This protects against malformed or forged credentials.
53. Test Token Claims Directly
We should also verify that token generation contains the expected claims.
Create:
tests/unit/test_security.py
Add:
from uuid import uuid4from app.core.security import ( create_access_token, decode_access_token,)def test_access_token_contains_user_subject() -> None: user_id = uuid4() token = create_access_token(user_id) payload = decode_access_token(token) assert payload["sub"] == str(user_id) assert "iat" in payload assert "exp" in payload
This verifies our token contract.
54. Testing Expiration
Token expiration deserves a dedicated test.
Instead of waiting 30 minutes, make token creation more testable.
Update:
def create_access_token( user_id: UUID, expires_delta: timedelta | None = None,) -> str: settings = get_settings() now = datetime.now(timezone.utc) if expires_delta is None: expires_delta = timedelta( minutes=settings.access_token_expire_minutes ) expires_at = now + expires_delta payload = { "sub": str(user_id), "iat": now, "exp": expires_at, } return jwt.encode( payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm, )
Production behavior remains unchanged.
Tests gain control over expiration.
55. Add the Expired Token Test
Add:
from datetime import timedeltaimport jwtimport pytest
Then:
def test_expired_access_token_is_rejected() -> None: user_id = uuid4() token = create_access_token( user_id, expires_delta=timedelta(seconds=-1), ) with pytest.raises(jwt.ExpiredSignatureError): decode_access_token(token)
We now explicitly test token lifetime enforcement.
56. Test Inactive User Login
We also need to verify:
is_active = False
blocks login.
Create a user through registration.
Then use a database session to set:
user.is_active = False
and commit.
Attempt login.
Expected:
403 Forbidden
This verifies that disabling an account prevents new authentication.
57. Test Existing Token After User Deactivation
There is another important case.
Sequence:
User logs in ↓Token issued ↓Administrator deactivates user ↓User calls protected endpoint
Because get_current_user loads the user from PostgreSQL, the request should return:
403 Forbidden
even though the token itself is still cryptographically valid.
This is exactly why we chose to perform current-user lookup on protected requests.
58. Run the Complete Test Suite
Run:
python -m pytest
The suite should now cover:
HealthDatabase connectivityTenant persistenceDatabase constraintsRegistrationPassword hashingEmail normalizationLoginPassword verificationJWT generationJWT validationToken expirationInvalid credentialsInvalid tokensCurrent userInactive users
All tests should pass before continuing.
59. No Database Migration Is Required
Part 8 does not add new persistent entities.
The existing schema already contains:
users.password_hashusers.is_active
JWT tokens are not stored in PostgreSQL in this implementation.
Therefore:
Alembic migration required?
Answer:
No
This is useful to notice.
Not every application capability requires a schema change.
60. Update the Application Version
Open:
app/core/constants.py
Change:
APP_VERSION = "0.0.5"
to:
APP_VERSION = "0.0.6"
Start:
python -m uvicorn app.main:app --reload
Check:
GET /api/v1/health
Expected:
{ "status": "healthy", "service": "quorentra-api", "version": "0.0.6", "database": "connected"}
61. Current Authentication Module
The authentication module now resembles:
auth/├── __init__.py├── dependencies.py├── exceptions.py├── router.py├── schemas.py└── service.py
Responsibilities are separated:
schemas.py ↓API contractsservice.py ↓Authentication use casesdependencies.py ↓Authenticated request contextexceptions.py ↓Authentication-domain errorsrouter.py ↓HTTP translation
This is a strong modular boundary for future expansion.
62. Current Request Architecture
The application now supports two authentication paths.
Registration:
Client ↓FastAPI ↓AuthService.register() ↓Repositories ↓PostgreSQL
Login:
Client ↓FastAPI ↓AuthService.login() ↓UserRepository ↓Password Verification ↓JWT Creation
Protected request:
Client ↓Bearer Token ↓get_current_user() ↓JWT Validation ↓UserRepository ↓Current User ↓Protected Endpoint
We now have a genuine authentication subsystem.
63. Why the Current User Dependency Is Important
Future endpoints should not repeatedly implement:
read Authorization headerdecode tokenextract UUIDload usercheck active
They can simply declare:
current_user: User = Depends( get_current_user)
That gives every protected capability a consistent authenticated identity.
For example, later:
GET /companiesPOST /contactsPOST /opportunities
will all begin from the same authenticated user context.
64. But We Still Do Not Know the Current Organization
Suppose the authenticated user belongs to:
Organization AOrganization BOrganization C
and sends:
GET /companies
Which organization’s companies should Quorentra return?
Authentication alone cannot answer that.
We need:
Current User +Requested Organization ↓Membership Validation ↓Current Tenant Context
That is the next architectural problem.
65. Why We Should Not Guess the Organization
A tempting shortcut would be:
Use the user's first membership.
That is unsafe.
Another shortcut would be:
Put one organization ID permanently in the JWT.
That limits multi-organization usage and mixes authentication with tenant selection.
Instead, we want explicit tenant context.
For example, a future request may provide:
X-Organization-ID: <uuid>
or use another explicit workspace-selection mechanism.
Quorentra will then validate:
Does current_user belong to this organization?
before exposing tenant data.
66. Tenant Context Will Become a Dependency
Part 9 can introduce something conceptually like:
def get_current_tenant( current_user = Depends(get_current_user), ...): ...
The flow becomes:
JWT ↓Current User ↓Organization Selection ↓Membership Lookup ↓Tenant Context
Then business endpoints can depend on:
TenantContext
rather than manually performing membership checks.
67. Why This Matters Before CRM Tables
We are getting close to creating:
companiescontactsopportunities
But before we do, Quorentra must have a safe answer to:
Which organization’s data is this request allowed to access?
Otherwise we risk building endpoints first and trying to retrofit tenant isolation later.
That would be dangerous.
The correct order is:
Authentication ↓Tenant Context ↓Authorization ↓CRM Data
68. ChatGPT Must Follow the Same Authentication Boundary
The architecture we are creating is not only for React.
Later ChatGPT might invoke Quorentra capabilities through MCP.
For example:
Show me my open opportunities.
The future flow must still be:
ChatGPT │ ▼Quorentra MCP │ ▼Authenticated Identity │ ▼Tenant Context │ ▼Authorized CRM Service
ChatGPT does not get a privileged bypass around Quorentra’s security model.
That is a foundational architectural rule.
69. MCP Must Reuse Application Services
When we eventually expose something such as:
list_opportunities()
through MCP, the MCP handler should not write SQL directly.
Instead:
MCP Tool │ ▼OpportunityService │ ▼Repository │ ▼PostgreSQL
The REST endpoint should use the same service:
FastAPI Route │ ▼OpportunityService
This ensures the web interface and ChatGPT interface operate under the same business rules.
Part 8’s separation between router, service, repository, and security context prepares us for exactly that architecture.
70. What We Are Not Building Yet
Part 8 deliberately does not add:
- refresh tokens;
- token revocation;
- logout blacklist;
- persistent sessions;
- MFA;
- password reset;
- email verification;
- OAuth;
- Google login;
- Microsoft Entra ID;
- SAML;
- organization selection;
- tenant context;
- role enforcement;
- permissions.
These may become important.
But none is required to prove our current vertical slice.
71. About Refresh Tokens
A production-grade authentication system often uses:
Short-Lived Access Token +Longer-Lived Refresh Token
The refresh token obtains a new access token without requiring the user to re-enter credentials.
We are deliberately not adding that yet.
Why?
Because our immediate goal is:
Register ↓Login ↓Authenticated Request
Once the MVO works end-to-end, authentication lifecycle enhancements can be added as a focused module.
72. About Logout
With stateless JWT access tokens, logout is not automatically equivalent to invalidating an already-issued token.
For the MVP, the client can discard the token.
A more advanced architecture could introduce:
refresh token revocationtoken family trackingsession recordsdeny lists
But those mechanisms introduce additional persistence and lifecycle complexity.
We should add them when the product needs them.
73. About Rate Limiting
Login endpoints are sensitive to brute-force attempts.
Production authentication should eventually include rate limiting based on signals such as:
IPaccountdevice/sessionrequest patterns
That belongs in the production security-hardening phase.
For local MVP development, we first need the core authentication flow working correctly.
74. Authentication Logging
We should also eventually record security events such as:
login succeededlogin faileduser deactivatedpassword changedtoken/session revoked
But security logs must never include:
passwordpassword hashcomplete bearer token
Observability will become a separate concern as Quorentra approaches deployment.
75. Acceptance Criteria
Part 8 is complete when:
✓ PyJWT is installed✓ JWT secret is environment-based✓ JWT algorithm is configurable✓ access-token lifetime is configurable✓ access tokens contain sub✓ access tokens contain iat✓ access tokens contain exp✓ user ID is the token subject✓ login request schema exists✓ login response schema exists✓ credentials are verified✓ unknown email is rejected✓ wrong password is rejected✓ login errors do not enumerate accounts✓ inactive users cannot log in✓ POST /api/v1/auth/login exists✓ successful login returns an access token✓ HTTP Bearer authentication exists✓ JWT signature is validated✓ JWT expiration is validated✓ invalid tokens return 401✓ expired tokens return 401✓ current user is loaded from PostgreSQL✓ inactive users cannot access protected endpoints✓ GET /api/v1/auth/me exists✓ /auth/me requires authentication✓ password hashes are never exposed✓ authentication tests pass✓ full test suite passes✓ Quorentra reports version 0.0.6
Most importantly:
A registered user can now authenticate and make an authenticated request.
76. Quorentra 0.0.6
Our application status is now:
Repository ✓FastAPI ✓PostgreSQL ✓SQLAlchemy ✓Alembic ✓Organizations ✓Users ✓Memberships ✓Password hashing ✓Registration ✓Organization onboarding ✓Login ✓JWT access tokens ✓Bearer authentication ✓Current user ✓Protected endpoint ✓Current organization -Tenant context -Membership enforcement -RBAC -React -Companies -Contacts -Opportunities -MCP -ChatGPT -AI -
This is another substantial milestone.
Quorentra can now answer:
Who is making this request?
77. The MVO Is Becoming a Real Application
Our development path now looks like:
Part 3Repository ✓ │Part 4FastAPI ✓ │Part 5PostgreSQL ✓ │Part 6Tenant Domain ✓ │Part 7Registration ✓ │Part 8Authentication ✓
The backend now supports a real lifecycle:
New User │ ▼Register │ ▼Organization Created │ ▼Owner Membership Created │ ▼Login │ ▼JWT │ ▼Authenticated Request
That is a meaningful vertical slice.
78. The Next Security Boundary
We now know:
current_user
But Quorentra is multi-tenant.
The next question is:
Which organization is this authenticated user currently operating inside?
That requires the membership model we built in Part 6.
The next architecture will connect:
Authentication +Membership ↓Tenant Context
This is the bridge between identity and CRM data isolation.
79. Part 9 Architecture Preview
Part 9 will introduce a tenant context such as:
TenantContext├── user├── organization└── membership
A request will follow:
Bearer Token │ ▼Current User │ ▼Organization Selection │ ▼MembershipRepository │ ▼Membership Valid? │ ├── No → 403 │ ▼TenantContext
Future CRM services will receive this context.
80. Why Tenant Context Must Exist Before Companies
Suppose we created:
GET /api/v1/companies
today.
We know the user.
But we still cannot safely construct:
SELECT *FROM companiesWHERE organization_id = ?
because we do not yet have the authorized organization ID.
Only after tenant context exists should we introduce tenant-owned CRM records.
This is security architecture driving development order.
81. Future ChatGPT Request
Consider a future user asking ChatGPT:
Which opportunities should I focus on this week?
That request may eventually flow through:
ChatGPT │ ▼Quorentra MCP │ ▼Authenticated User │ ▼Tenant Context │ ▼OpportunityService │ ▼OpportunityRepository │ ▼PostgreSQL
The AI layer appears near the top.
The security and tenant architecture remain underneath.
That is exactly what we want.
82. The Architectural Principle
The key principle emerging from Parts 6–8 is:
AI must consume authorized application capabilities, not bypass application boundaries.
We are therefore not building:
ChatGPT ↓Database
We are building:
ChatGPT ↓MCP ↓Application Services ↓Authorization ↓Repositories ↓Database
That architecture will allow Quorentra to become deeply ChatGPT-native without weakening its CRM security model.
83. Next Article
Tenant Context and Organization Isolation
We will introduce:
- organization selection;
- organization request headers;
- tenant context modeling;
- membership resolution;
- authenticated tenant dependencies;
- organization-access validation;
- unauthorized-tenant rejection;
- membership-aware request processing;
- tenant-scoped service architecture;
- organization isolation tests;
- multi-organization user scenarios;
- preparation for tenant-owned CRM entities.
The architecture will evolve from:
JWT ↓Current User
to:
JWT ↓Current User ↓Organization Selection ↓Membership Validation ↓Tenant Context
Once that boundary exists, Quorentra will finally have everything necessary to begin safely introducing the first CRM business records.
Quorentra now knows who the user is.
Next, it needs to know which organization that user is allowed to act for.