Framework guide
Python
Install the Python SDK and build auth and data flows in FastAPI or Flask — with pagination, optimistic concurrency, atomic batches and typed errors.
Python means server, which means secret key
Almost all Python runs on a server, so secret_key is usually right. It carries full authority over every user in the app — which means the per-user scoping a browser gets automatically is now your job. Reach for publishable_key only when your service acts on behalf of one signed-in end user.
Install the SDK
Bashpip install neuctra-authix # or with poetry / uv poetry add neuctra-authix uv add neuctra-authixAdd your keys
Bash# .env — never commit this file AUTHIX_APP_ID=app_xxxxxxxxxxxx AUTHIX_SECRET_KEY=sk_live_xxxxxxxx_xxxxxxxxxxxxxxxxCreate the client
Python1# app/authix.py 2import os 3 4from neuctra_authix import Authix 5 6# One client for the process. It holds a connection pool, so building a new one 7# per request throws away every kept-alive connection. 8authix = Authix( 9 app_id=os.environ["AUTHIX_APP_ID"], 10 secret_key=os.environ["AUTHIX_SECRET_KEY"], 11)Keys are checked before any request
Passing a
pk_key assecret_key, or a malformed key, raisesConfigurationErrorat construction — so a misconfigured deploy fails at boot rather than on its first real request.Make your first calls
Python1from neuctra_authix import Authix, VersionConflictError 2 3authix = Authix(app_id=APP_ID, secret_key=SECRET_KEY) 4 5# Create a user 6result = authix.signup_user( 7 name="Ada Lovelace", 8 email="[email protected]", 9 password="a-strong-password", 10) 11user_id = result["user"]["id"] 12 13# Store a record they own 14record = authix.add_user_data( 15 user_id=user_id, 16 data_category="notes", 17 data={"title": "First note", "body": "Hello world"}, 18) 19 20print(record["data"]["id"], record["data"]["version"])
Reading data
Every list and search is bounded. There is no “fetch everything” mode, because one such call against a large app would load the whole dataset into memory.
1# One page — 20 records by default, 100 maximum.
2page = authix.get_user_data(user_id=user_id, category="notes", limit=50)
3
4for note in page: # a Page iterates its records
5 print(note["title"])
6
7page.has_more # bool
8page.next_cursor # pass back as cursor=... for the next page
9
10# Or let the SDK follow the cursor for you.
11for note in authix.iter_user_data(user_id=user_id, category="notes"):
12 process(note)
13
14# Bound the traversal when the size is unknown.
15for row in authix.iter_all_users_data(q="invoice", max_pages=10):
16 process(row)FastAPI
Auth endpoints, plus the scoping pattern that matters most.
1# app/main.py
2from fastapi import Depends, FastAPI, HTTPException, Request
3from pydantic import BaseModel
4
5from neuctra_authix import (
6 AuthenticationError,
7 AuthixAPIError,
8 InsufficientScopeError,
9 NotFoundError,
10 ValidationError,
11)
12
13from .authix import authix
14
15app = FastAPI()
16
17
18class Credentials(BaseModel):
19 email: str
20 password: str
21
22
23class SignupBody(Credentials):
24 name: str
25
26
27@app.post("/auth/signup", status_code=201)
28def signup(body: SignupBody):
29 result = authix.signup_user(
30 name=body.name, email=body.email, password=body.password
31 )
32
33 # An unverified account can sign in but cannot store records, so send the
34 # code now rather than letting the first write fail with a 403.
35 authix.request_email_verification_otp(
36 user_id=result["user"]["id"], email=body.email
37 )
38
39 return {"user_id": result["user"]["id"]}
40
41
42@app.post("/auth/login")
43def login(body: Credentials):
44 return authix.login_user(email=body.email, password=body.password)
45
46
47def current_user(request: Request) -> str:
48 """Your own session check. Whatever it is, the id must come from here."""
49 user_id = request.session.get("user_id")
50 if not user_id:
51 raise HTTPException(status_code=401, detail="Not signed in")
52 return user_id
53
54
55@app.get("/notes")
56def list_notes(cursor: str | None = None, user_id: str = Depends(current_user)):
57 page = authix.get_user_data(
58 user_id=user_id, # <- trusted, never from the request body
59 category="notes",
60 limit=20,
61 cursor=cursor,
62 )
63
64 return {
65 "data": page.data,
66 "has_more": page.has_more,
67 "next_cursor": page.next_cursor,
68 }Never take the user id from the request
With a secret key, user_id is whatever you pass. If it comes from the request body or a query parameter, any caller can read any user by changing one value — and your own client will never reveal the bug, because it always sends the right id.
Turning SDK errors into HTTP responses
Every failure is a typed exception, so you can map them once and forget about status codes at the call site.
1# app/errors.py
2from fastapi import Request
3from fastapi.responses import JSONResponse
4
5from neuctra_authix import (
6 AuthixAPIError,
7 InsufficientScopeError,
8 VersionConflictError,
9)
10
11
12def install(app):
13 @app.exception_handler(AuthixAPIError)
14 def handle(request: Request, exc: AuthixAPIError):
15 # A scope error means this server used the wrong key. That is our bug,
16 # so it must not surface as a 403 the caller might try to "fix".
17 if isinstance(exc, InsufficientScopeError):
18 app.logger.error("wrong Neuctra Authix key for %s: %s", request.url.path, exc.hint)
19 return JSONResponse({"message": "Server misconfiguration."}, 500)
20
21 body = {"message": exc.message, "code": exc.code}
22
23 if isinstance(exc, VersionConflictError):
24 body["current_version"] = exc.current_version
25
26 # Passing the status through keeps a 409 a 409 — the caller can retry
27 # a conflict, and can do nothing at all with a 500.
28 return JSONResponse(body, exc.status)Flask
The same shape, with Flask's idioms.
1# app.py (Flask)
2from flask import Flask, g, jsonify, request, session
3
4from neuctra_authix import AuthixAPIError
5
6from .authix import authix
7
8app = Flask(__name__)
9
10
11@app.before_request
12def load_user():
13 g.user_id = session.get("user_id")
14
15
16@app.errorhandler(AuthixAPIError)
17def on_authix_error(exc):
18 return jsonify(message=exc.message, code=exc.code), exc.status
19
20
21@app.get("/notes")
22def list_notes():
23 if not g.user_id:
24 return jsonify(message="Not signed in"), 401
25
26 page = authix.get_user_data(
27 user_id=g.user_id,
28 category="notes",
29 limit=20,
30 cursor=request.args.get("cursor"),
31 )
32
33 return jsonify(
34 data=page.data, has_more=page.has_more, next_cursor=page.next_cursor
35 )Two writers, one record
Pass the version you last read and a competing change is rejected instead of silently overwritten.
1from neuctra_authix import VersionConflictError
2
3record = authix.get_single_user_data(user_id=user_id, data_id=data_id)
4
5try:
6 authix.update_user_data(
7 user_id=user_id,
8 data_id=data_id,
9 data={"status": "shipped"},
10 version=record["data"]["version"],
11 )
12except VersionConflictError as exc:
13 # Another writer got there first. Nothing was overwritten.
14 print("changed since read; now at version", exc.current_version)Changing records together
When a partial write would leave your data inconsistent.
1# Every operation commits, or none of them do.
2authix.batch(
3 user_id=user_id,
4 operations=[
5 {"type": "create", "dataCategory": "orders", "total": 40},
6 {"type": "update", "id": stock_id, "version": 3, "remaining": 9},
7 {"type": "delete", "id": draft_id},
8 ],
9)Searching
keys is an exact match, q is a substring match. Both execute in the database against an index, not in Python.
1# Exact field match — a containment query, index-backed in Postgres.
2paid = authix.search_in_user_data(user_id=user_id, keys={"status": "paid"})
3
4# Substring match across the whole record.
5invoices = authix.search_in_all_app_users_data(q="invoice", limit=100)
6
7# Across users — secret key only.
8admins = authix.search_in_all_app_users(keys={"role": "admin", "isActive": True})User search is restricted to an allowlist — id, username, name, email, phone, address, role, isVerified, isActive. Filtering on anything else raises ValidationError, so credential columns are never reachable.
Short-lived processes
Scripts and jobs should release the connection pool when they finish.
1# Short-lived processes — a CLI, a cron job, a test — should close the
2# connection pool deterministically.
3with Authix(app_id=APP_ID, secret_key=SECRET_KEY) as authix:
4 for user in authix.iter_all_users():
5 print(user["email"])Project structure
A layout that keeps the scoping decision somewhere you cannot miss it.
app/
├── authix.py # one client for the process
├── main.py # FastAPI app + routes
├── errors.py # AuthixAPIError -> HTTP responses
└── routes/
├── auth.py # signup, verify, login
├── notes.py # per-user data, scoped by YOU
└── admin.py # cross-user readsCommon mistakes
- Building a client per request. It discards the connection pool. Build one at import time and reuse it.
- Trusting a client-supplied user id. The single most common way a server-side integration leaks data.
- Catching bare
Exception. CatchVersionConflictErrororNotFoundErrorand act on them; letting the rest surface tells you when something is genuinely wrong. - Expecting an unbounded read. Use
iter_*withmax_pages. - Skipping email verification. The account can log in, but every write fails with 403 until it is verified.
You now have
- Signup, verification and login endpoints.
- Per-user reads scoped to a session you verified.
- Paged traversal that will not exhaust memory.
- Errors mapped once, with their status codes intact.
Retrying a conflict
A VersionConflictError is not a failure to log and forget — it means someone else wrote first. Re-read the record, reapply your change to the fresh version, and try again. Two or three attempts is plenty; beyond that something else is wrong.
Related