Quorentra

Quorentra CRM JWT Authentication: Building from Zero — Part 8

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.

Quorentra JWT Authentication: Building from Zero — Part 8
Quorentra JWT Authentication: Building from Zero — Part 8

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:

password
password_hash
API keys
database credentials
private customer information

The token should contain only the minimum claims required to establish authenticated identity.

For our MVP, that primarily means:

sub
iat
exp

where:

sub = user identifier
iat = issued-at time
exp = 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:

email
password_hash
is_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-validator
PyJWT

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:

Header
Payload
Signature
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=HS256
ACCESS_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=HS256
ACCESS_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_KEY
JWT_ALGORITHM
ACCESS_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, timezone
from uuid import UUID
import jwt
from 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):
pass
class InvalidCredentialsError(
AuthenticationError
):
pass
class InactiveUserError(
AuthenticationError
):
pass
class 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_in
refresh_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 email
wrong password
invalid token
expired 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:

active
suspended
locked
pending_verification
disabled

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 UUID
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import (
HTTPAuthorizationCredentials,
HTTPBearer,
)
from sqlalchemy.orm import Session
from app.core.security import decode_access_token
from app.db.dependencies import get_db
from app.modules.users.model import User
from 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:

disabled
deleted
suspended

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:

sub
exp

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
Authenticated
Expired 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 uuid4
from fastapi.testclient import TestClient
from app.main import app
client = 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 uuid4
from 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 timedelta
import jwt
import 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:

Health
Database connectivity
Tenant persistence
Database constraints
Registration
Password hashing
Email normalization
Login
Password verification
JWT generation
JWT validation
Token expiration
Invalid credentials
Invalid tokens
Current user
Inactive 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_hash
users.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 contracts
service.py
Authentication use cases
dependencies.py
Authenticated request context
exceptions.py
Authentication-domain errors
router.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 header
decode token
extract UUID
load user
check 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 /companies
POST /contacts
POST /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 A
Organization B
Organization 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:

companies
contacts
opportunities

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 revocation
token family tracking
session records
deny 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:

IP
account
device/session
request 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 succeeded
login failed
user deactivated
password changed
token/session revoked

But security logs must never include:

password
password hash
complete 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 3
Repository
Part 4
FastAPI
Part 5
PostgreSQL
Part 6
Tenant Domain
Part 7
Registration
Part 8
Authentication

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 companies
WHERE organization_id = ?

because we do not yet have the authorized organization ID.

Part 9 will solve that.

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

In Part 9, we will build:

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.

Quorentra

Contact

Menu

Designed with WordPress

Discover more from Quorentra

Subscribe now to keep reading and get access to the full archive.

Continue reading