Stackure Python SDK
Protect ASGI and WSGI apps, such as FastAPI, Flask and Django. Zero dependencies.
The stackure package adds Stackure sign-in to a Python app with one line of middleware. For how sign-in works in every SDK, see the overview. To call the API without an SDK, see the API reference.
Install
pip install stackure
It needs Python 3.14 or later. The package is stackure on PyPI.
Configure
export STACKURE_APP_ID=... # the app's ID, shown on the app's page in Stackure
export STACKURE_APP_SECRET=... # shown once, when you register the app or rotate its secret
export STACKURE_BASE_URL=https://stackure.com
- The SDK reads these from the environment, so no function takes an app ID or secret.
- The secret goes in the
X-App-Secretheader on every call except sign-out. - The first call that needs the app ID or secret raises
StackureError("validation")if it's missing. An app ID that isn't a UUID raises the same error. Inauthandverifythis shows up as a 500, inmcpas a 503. STACKURE_BASE_URLdefaults tohttps://stackure.com. Each Stackure region has its own address. This page is for https://stackure.com. Use the region where your organization was created.
Until you share a new app, only your organization's app admins and owner can sign in to it. Share it with everyone or with chosen teams under Available To on the app's page.
Protect an app
import stackure
# ASGI: FastAPI, Starlette, Quart
app = stackure.auth()(app)
# WSGI: Flask
flask_app.wsgi_app = stackure.auth()(flask_app.wsgi_app)
# WSGI: Django, in wsgi.py
application = stackure.auth()(get_wsgi_application())
The same wrapper handles both. It tells ASGI from WSGI by the app you wrap. Requests that aren't signed in never reach your app:
- A browser request, one whose
Acceptheader includestext/htmland notapplication/json, is redirected to sign in. - Any other request gets a JSON 401 with the sign-in URL. A check that can't be completed gets a JSON 500.
authchecks HTTP requests only. ASGI WebSocket and lifespan events pass through unchecked.
The JSON 401 looks like this:
{"error": "Unauthorized", "message": "Valid authentication required", "sign_in_url": "https://stackure.com/sign-in/magic-link?app_id=..."}
The signed-in user
user = stackure.user_from_request(request)
print(user.user_email, user.account_id)
user_from_request takes a framework request, a WSGI environ or an ASGI scope. It returns the User that auth or mcp attached. Without one, it returns None.
| Field | Value |
|---|---|
user_id | The user's ID |
account_id | The ID of the user's organization |
user_email | Email address |
user_first_name | First name |
user_last_name | Last name |
user_is_app_admin | The user is an app admin or the owner in their organization, in charge of its apps |
user_teams | The teams the user belongs to, a tuple of Team(team_id, team_name), empty when none |
Stackure defines no permissions inside your app. Your app decides what these facts mean.
List who can open the app
List the users in the caller's organization who can open the app, and the teams that have it, for sharing and assigning inside your app. Each call returns one page, sorted by name:
page = stackure.directory_users(request, query="ada")
# page.items: DirectoryUser(user_id, user_email, user_first_name, user_last_name)
# page.next_cursor, page.total
teams = stackure.directory_teams(request, limit=50)
# teams.items: Team(team_id, team_name)
querymatches a user's name, email and job title, or a team's name.limitis the page size: 100 by default, 200 at most.idsreturns only those IDs that can open the app, up to 200, to check or name people and teams you already store.- Pass
next_cursorascursorfor the next page. It'sNoneon the last one. totalis set on the first page when there are 10,000 matches or fewer.- Both take what
verifytakes and use the request's session cookie, so call them from a route behindauth. MCP bearer tokens aren't accepted. - No valid session raises
StackureError("auth"). - They're synchronous. In an
async defview, useawait asyncio.to_thread(stackure.directory_users, request).
Sign-in handoff
You don't write this part. For what the user sees, see the overview. After they sign in:
- Stackure posts a
session_tokenform field to your app's registered URL. - The middleware checks the token with Stackure, stores it in a
stackure_sessioncookie on your domain and redirects to the same URL as a GET.
- The cookie is
HttpOnly,SameSite=LaxandPath=/. It lasts 7 days and isSecurewhen the request came over HTTPS, directly or through a proxy that setsX-Forwarded-Proto. It isn't namedsession, which Flask uses for its own session. - The registered URL must be behind
auth, since that's where the token arrives. Users land there after signing in, not on the page they started from. - The middleware takes the token only from the origin in
STACKURE_BASE_URL. It ignores invalid tokens and handoff bodies over 4 KB. - Users who can't open your app are stopped at Stackure and never reach it.
- Someone already signed in to Stackure is handed in without signing in again.
Sessions
- Every request with a session cookie is checked with Stackure, so signing out or losing access to the app takes effect on the next request. A request without a well-formed token gets the sign-in URL with no call.
- A session lasts 1 day from sign-in. Each person has one session at a time: signing in again, on any device, ends the earlier one, in your app too.
- Sessions aren't bound to the browser's user agent or IP. The SDK still forwards the original
User-AgentandX-Forwarded-For, for information only.
Protect an MCP endpoint
AI clients such as Claude, Claude Code, VS Code and Cursor sign users in through Stackure and send the credential it issues as Authorization: Bearer. Wrap your MCP app with mcp and route to it beside the rest of your app, not behind auth:
from starlette.applications import Starlette
from starlette.routing import Mount
app = Starlette(routes=[
Mount("/mcp", stackure.mcp()(mcp_app)),
Mount("/", stackure.auth()(web_app)),
])
For WSGI, for example with Flask:
from werkzeug.middleware.dispatcher import DispatcherMiddleware
flask_app.wsgi_app = DispatcherMiddleware(
stackure.auth()(flask_app.wsgi_app),
{"/mcp": stackure.mcp()(mcp_wsgi_app)},
)
- This one line checks every MCP request with Stackure in real time, with the same app ID and secret as
auth. There's nothing else to set up. - It reads only
Authorization: Bearerand ignores cookies.user_from_requestworks the same as withauth. - A request that isn't signed in gets a 401 with the body
{"error":"unauthorized"}and aWWW-Authenticateheader that tells the AI client where to sign in. A failed check gets a 503 with{"error":"unavailable"}. It never redirects and never sets a cookie. - With a Starlette or FastAPI
Mount, the address AI clients use ends in a slash:/mcp/. - The MCP endpoint must be on the same host and port as the app's registered URL, unless an MCP URL is set for the app in Stackure. The SDK builds the endpoint's address from the request's scheme,
Hostheader and path, so behind a proxy make sure the originalHostandX-Forwarded-Protoreach your app. - Access tokens last 1 hour and AI clients renew them on their own. A connection lasts at most 90 days and ends sooner if unused for 30 days. Signing out, disconnecting the client and losing access to your app also end it.
- Users see and disconnect their AI clients in Stackure under AI Clients in their profile.
Verify without middleware
result = stackure.verify(request)
if not result.authenticated:
# result.error.code, result.error.message, result.error.sign_in_url
...
# result.user
verifynever raises. A request that isn't signed in comes back witherror.code401 and a sign-in URL. A transport or API failure comes back as 500.- It takes a WSGI
environ, an ASGIscopeor a request object from Starlette, FastAPI, Flask or Django. - It's synchronous. In an
async defview, useawait asyncio.to_thread(stackure.verify, request). stackure.validate_session(stackure.to_request(request))returns the rawSession(authenticated,user,sign_in_url) and raisesStackureErrorinstead.
Send a sign-in link
resp = stackure.send_magic_link("user@example.com")
# resp.message
- Stackure emails the user a link that works for 15 minutes. Opening it asks the user to confirm with Continue on Stackure. It then signs them in and, if they can open the app, sends them to your app's registered URL.
- The response is the same whether or not the email belongs to a Stackure user.
- A malformed email raises
StackureError("validation")before any call. - Sending is limited to 3 requests per minute and 5 requests per hour for each email and IP address. Past that, it raises
StackureError("network")withstatus_code429.
Sign out
METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
# Flask
@app.route("/logout", methods=METHODS)
def logout():
r = stackure.logout(request)
return "", r.status, r.headers
# FastAPI
@app.api_route("/logout", methods=METHODS, include_in_schema=False)
async def logout(request: Request):
r = await asyncio.to_thread(stackure.logout, request)
return Response(status_code=r.status, headers=dict(r.headers))
# Starlette: the same function, routed with
Route("/logout", logout, methods=METHODS)
<form method="post" action="/logout"><button>Sign Out</button></form>
- Route every method on the sign-out path to
logout. Trigger it with a form or button that POSTs from your app's own page. - That POST signs the user out of Stackure everywhere with one server-side call, clears the app's cookie and redirects to Stackure. If the call fails, the cookie is still cleared and the redirect goes to the Stackure sign-out page, where the user can finish signing out.
- A link or any other request is sent to the Stackure sign-out page, where the user confirms. No call is made and the cookie stays. The cookie is
SameSite=Lax, so without this a link on another site could sign the user out. - A POST counts as from your own page when
Sec-Fetch-Siteissame-originor, only when that header is missing, whenOriginmatchesHostand ishttpsif the request came over HTTPS. A request that sends any of these headers more than once never counts. logoutis synchronous and never raises. It returns aRedirect(status,headers) for your framework to send. It can block for up to 2 seconds, so theasync defview runs it withasyncio.to_thread.
Errors
Every function except verify and logout raises StackureError. Those two never raise. Branch on .code:
from stackure import StackureError
try:
stackure.send_magic_link(email)
except StackureError as err:
match err.code:
case "validation": ...
case "auth": ...
case "forbidden": ...
case "timeout": ...
case "network": ...
| Code | When |
|---|---|
validation | Bad input or a missing or malformed setting, caught before any call |
auth | 401 from the API, such as for a wrong app secret |
forbidden | 403 from the API |
timeout | The 2-second deadline passed |
network | Everything else, including 429 and 5xx answers |
err.message says what happened. err.status_code is the HTTP status. It's None when no answer came back.
Timeouts and limits
- Every call has one 2-second deadline covering connect, headers, body and the single retry.
- A call that gets a 5xx or a connection failure is retried once after 500 ms, if the deadline allows. Timeouts are never retried.
- A timeout anywhere, including while reading the body, raises
StackureError("timeout"). - Session checks, MCP checks and directory calls (users and teams together) each have a limit of 1,000 requests per minute per app. Over it, Stackure answers 429:
authandverifygive a 500,mcpa 503 anddirectoryraisesStackureError("network")withstatus_code429.
More
- Overview: how sign-in works in every SDK.
- API Reference: the endpoints this SDK calls.
- Source on GitHub. Report vulnerabilities through GitHub Security Advisories.
- Releases publish to PyPI through trusted publishing, each with a GitHub build provenance attestation.