EpsimoAI Backend — Authentication Guide¶
This document describes every authentication mechanism the backend supports, what each is for, and how to use it from a CLI or external code.
Backend base URL (production): https://backend.epsimoagents.com
Frontend (production): https://chat.epsimoagents.com
Audience. Developers integrating with the EpsimoAI backend, building CLI tools, embedding assistants in third-party platforms, or wiring up SSO from another identity provider.
At a glance¶
| # | Mechanism | Endpoint | Token format | Lifetime | Best for |
|---|---|---|---|---|---|
| 1 | Email + password login | POST /auth/login |
EpsimoAI JWT (HS256) | 30 days | Frontend login, personal CLI |
| 2 | Email + password signup | POST /auth/signup + email verification |
n/a (then login) | n/a | New user registration |
| 3 | Google OAuth | GET /auth/google/login → /auth/google/callback |
EpsimoAI JWT (HS256) | 30 days | "Sign in with Google" flow |
| 4 | Project-scoped JWT | GET /projects/{project_id} |
EpsimoAI JWT (HS256) | 30 days | Pinning a token to a single project workspace |
| 5 | Cognito → EpsimoAI exchange | POST /auth/exchange |
EpsimoAI JWT (RS256) | 1 hour | Cross-app SSO from a Cognito-protected app |
| 6 | Per-assistant public token | Set in assistant config | Plain string secret | Until rotated in config | Embedding ONE assistant in Typebot, Zapier, etc. |
| 7 | Public no-auth assistant | Set in assistant config | None | n/a | Fully open assistant endpoint |
| 8 | Admin token | ?admin_token=... query param |
Static env-var secret | Until rotated | Sysadmin cleanup endpoints only |
There is no general-purpose, long-lived per-user "API key" feature today (revocable, scoped, no expiry). If you need that, see "Gaps" at the end.
1. Email + password login¶
Standard username/password authentication. Used by the regular frontend login page.
Endpoint¶
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "your-password"
}
Response¶
{
"jwt_token": "eyJ...",
"main_agent_id": "<assistant-id>",
"main_thread_id": "<thread-id>"
}
What's in the JWT¶
{
"user_id": "<uuid>",
"project_id": "<uuid of user's main project>",
"alg": "HS256",
"iss": "<JWT_ISSUER env, e.g. LOCAL>",
"aud": "<JWT_AUDIENCE env, e.g. epsimoai>",
"exp": <30 days from now>
}
Using the token¶
Pass it in Authorization: Bearer <jwt_token> on every subsequent request:
curl https://backend.epsimoagents.com/threads \
-H "Authorization: Bearer eyJ..."
Lifetime, revocation¶
- 30 days, no refresh, no built-in renewal endpoint. Re-login to get a new token.
- No revocation. A leaked token is valid for the remainder of its 30 days unless the entire
JWT_SECRET_KEY/JWT_DECODE_KEY_B64is rotated server-side (which invalidates every token).
2. Email + password signup¶
Endpoint¶
POST /auth/signup
Content-Type: application/json
{
"email": "newuser@example.com",
"password": "...",
"first_name": "...",
"last_name": "..."
}
Flow¶
- Account created in
pendingstate. - Verification email sent (subject contains a 6-digit code).
- User submits the code via
POST /auth/verify-email→ account activated. - After activation, normal login at
POST /auth/loginworks.
Signup does NOT return a JWT directly — the user must verify email first, then log in.
3. Google OAuth ("Sign in with Google")¶
Used by the frontend's Se connecter avec Google button. Browser-based flow only — not designed for CLI use.
Endpoints¶
| Method | Path | Purpose |
|---|---|---|
GET |
/auth/google/login |
Redirects browser to Google's consent screen |
GET |
/auth/google/callback |
Receives Google's authorization code, mints a JWT, redirects to the frontend |
Flow¶
- User clicks "Sign in with Google" → browser navigates to
/auth/google/login. - Backend redirects to Google with a
redirect_uriof<BACKEND_URL>/auth/google/callback. - User consents.
- Google redirects back to
/auth/google/callback?code=.... - Backend exchanges the code with Google, gets the user's email/name.
- Backend looks up or creates an EpsimoAI user with
provider="google". - Backend mints the same kind of HS256 JWT as
POST /auth/login. - Backend redirects browser to:
<FRONTEND_URL>/auth-callback?jwt_token=<...>&main_agent_id=<...>&main_thread_id=<...> - Frontend extracts and stores the JWT.
Required server config¶
| Env var | Purpose |
|---|---|
BACKEND_URL |
Used to build the Google redirect URI; must match what's registered in the OAuth client |
FRONTEND_URL |
Where to redirect the browser after success (e.g. https://chat.epsimoagents.com) |
GOOGLE_OAUTH_CLIENT_ID |
OAuth 2.0 client ID from Google Cloud Console |
GOOGLE_OAUTH_SECRET_ID |
OAuth 2.0 client secret (store in Secrets Manager — DO NOT log) |
Required Google Cloud Console config¶
On the OAuth 2.0 Client (in APIs & Services → Credentials):
- Authorized JavaScript origins must include the frontend, e.g.:
https://chat.epsimoagents.com- Authorized redirect URIs must include exactly
<BACKEND_URL>/auth/google/callback, e.g.: https://backend.epsimoagents.com/auth/google/callback
The OAuth consent screen must be configured (app name, privacy policy, ToS) and either published or have the user as a test user.
Conflict with email-password accounts¶
If a user already exists with the same email but provider="email", the backend refuses Google login for that email and shows a "use password instead" page. There is no automatic account merging.
4. Project-scoped JWT¶
For users with access to multiple projects, you can mint a JWT pinned to a specific project. Routes that operate on threads/assistants/etc. read the project_id claim from this token and scope DB queries accordingly.
Endpoint¶
GET /projects/{project_id}
Authorization: Bearer <existing user JWT>
Response¶
{ "jwt_token": "eyJ..." }
The new token is identical to a fresh login token, except the project_id claim is fixed to the requested project.
Access control¶
The endpoint calls storage.get_project(user_id, project_id). If the calling user does not own the project, it returns 404 Not Found — no privilege escalation.
Listing available projects first¶
curl https://backend.epsimoagents.com/projects/ \
-H "Authorization: Bearer <user_jwt>"
Example end-to-end CLI¶
# Step 1: Login
JWT=$(curl -s -X POST https://backend.epsimoagents.com/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"..."}' \
| jq -r .jwt_token)
# Step 2: List projects, pick one
curl -s https://backend.epsimoagents.com/projects/ \
-H "Authorization: Bearer $JWT" | jq
# Step 3: Switch to that project
PROJECT_JWT=$(curl -s "https://backend.epsimoagents.com/projects/<project-id>" \
-H "Authorization: Bearer $JWT" \
| jq -r .jwt_token)
# Step 4: Use the project-scoped JWT
curl https://backend.epsimoagents.com/threads \
-H "Authorization: Bearer $PROJECT_JWT"
Caveats¶
- Same signing secret as the user JWT. A leaked user JWT can mint project-scoped JWTs, so this is a convenience, not an isolation boundary.
- No revocation. Stateless 30-day token.
- User can only switch to projects they own.
5. Cognito → EpsimoAI exchange¶
For SSO from an external app that authenticates users against an AWS Cognito user pool. The external app obtains a Cognito ID token, exchanges it for an EpsimoAI JWT, and uses that to call the backend on behalf of the user.
Endpoint¶
POST /auth/exchange
Authorization: Bearer <cognito-id-token>
Response¶
{ "token": "eyJ..." }
What the backend does¶
- Reads the Cognito ID token from the
Authorization: Bearer ...header. - Verifies it cryptographically against Cognito's JWKS (
https://cognito-idp.<region>.amazonaws.com/<pool-id>/.well-known/jwks.json): - signature with the right
kid issmatches the Cognito issuer URLaudmatchesCOGNITO_APP_CLIENT_ID- not expired
- Extracts
emailandsubfrom the verified payload. - Looks up or creates a user with
provider="cognito"in the EpsimoAI DB. - Ensures the user has a
mainproject (creates it if missing). - Mints an EpsimoAI JWT signed with RS256 (different algorithm from login/Google), 1-hour expiry:
json { "user_id": "<uuid>", "project_id": "<main project uuid>", "email": "<email>", "iss": "<EPSIMOAI_JWT_ISSUER>", "aud": "<EPSIMOAI_JWT_AUDIENCE>", "exp": <1 hour from now>, "iat": <now> }
Required server config¶
| Env var | Purpose |
|---|---|
COGNITO_USER_POOL_ID |
e.g. us-east-1_AbCdEfGhI |
COGNITO_APP_CLIENT_ID |
The Cognito app client whose tokens you want to accept |
AWS_REGION |
Region of the user pool (defaults to us-east-1) |
EPSIMOAI_JWT_ISSUER |
Issuer claim on the minted EpsimoAI token |
EPSIMOAI_JWT_AUDIENCE |
Audience claim (defaults to issuer if unset) |
EPSIMOAI_JWT_PRIVATE_KEY |
RSA private key, PEM format, for RS256 signing |
The corresponding public key must be configured in JWT_DECODE_KEY_B64 (or the auth handler must accept multiple algorithms/keys) so the rest of the API can verify the RS256 tokens this endpoint mints. The default deployment uses HS256 for login tokens, so accepting both algorithms requires either:
- A code change in
app/auth/handlers.pyto try multiple key/alg combinations, or - Setting up the auth handler to use a JWKS endpoint that publishes multiple keys.
Status. The endpoint exists in code but the production deployment does not currently set the Cognito or RS256 env vars, and the auth handler is configured for HS256 only. Calling the endpoint today returns
500 Cognito configuration error.
Use cases¶
- LeadGenius (or any other Cognito-protected app) wants a logged-in Cognito user to call the EpsimoAI API as a corresponding EpsimoAI user without a separate password login.
- A multi-product SSO setup where Cognito is the identity provider.
6. Per-assistant public token (OpenAI-compatible)¶
For embedding a single AI assistant in an external platform that expects an OpenAI-compatible API (Typebot, Zapier, no-code tools, etc.). Per-assistant, not per-user. Acts like a per-assistant API key.
Configuring an assistant for public-token access¶
Edit the assistant's config (via the frontend or PATCH /assistants/<id>) to set:
| Config key | Value |
|---|---|
type==agent/access_mode |
public_token |
type==agent/public_token |
a secret string you generate (e.g. pubkey_<random>) |
This token is what callers will use as the API key.
Calling — OpenAI-compatible style¶
This is the easiest path because Typebot, OpenAI client SDKs, etc. all support it natively:
curl https://backend.epsimoagents.com/public/openai/v1/chat/completions \
-H "Authorization: Bearer <your-public-token>" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o",
"messages": [{"role":"user","content":"Hello"}],
"stream": false
}'
The token alone identifies which assistant to use — there's no separate assistant_id field. The backend looks up the assistant whose type==agent/public_token matches.
In Typebot:
- Base URL: https://backend.epsimoagents.com/public/openai/v1
- API Key: the public token you set in the assistant config
Calling — native streaming endpoint¶
For finer control over input format, use the EpsimoAI-specific streaming endpoint:
curl https://backend.epsimoagents.com/public/runs/stream \
-H "X-Public-Bot-Token: <your-public-token>" \
-H "Content-Type: application/json" \
-d '{
"assistant_id": "<assistant-id>",
"input": { ... }
}'
The token can be passed in the X-Public-Bot-Token header or in the JSON body's token field.
Properties¶
- Per-assistant scope. The token only grants access to that one assistant.
- Rotation: edit the assistant config and change the value.
- No user identity. The "user" recorded in DB queries is the assistant's
project_id. There is no per-caller identity. - No quotas / rate limits per-token in the codebase as written.
7. Public no-auth assistant¶
For assistants that should be fully open with no token at all (e.g. a public demo bot).
Configuration¶
| Config key | Value |
|---|---|
type==agent/access_mode |
public_no_auth |
Calling¶
Same endpoints as #6 above, but no Authorization header / no X-Public-Bot-Token is required.
curl https://backend.epsimoagents.com/public/runs/stream \
-H "Content-Type: application/json" \
-d '{ "assistant_id": "<id>", "input": { ... } }'
When to use¶
- Public demos.
- Internal tools where access is controlled at the network level (e.g. behind a VPN).
Caveats¶
- No rate limiting in code. If the assistant uses a paid LLM (OpenAI, etc.), exposure means anyone can run up your bill.
- The assistant's
project_id"owns" the conversations.
8. Admin token¶
For low-level sysadmin endpoints — not part of the regular API. Used by maintenance scripts (setup_admin.sh, daily_stats_curl.py).
How it works¶
Set ADMIN_TOKEN as an env var on the backend, then pass it as a query parameter:
curl "https://backend.epsimoagents.com/admin/system-stats?admin_token=<ADMIN_TOKEN>"
curl "https://backend.epsimoagents.com/admin/inactive-users?admin_token=<ADMIN_TOKEN>&min_days_old=90"
Surface¶
/admin/... endpoints only — currently:
- GET /admin/system-stats — usage statistics
- GET /admin/inactive-users — list inactive users
- POST /admin/cleanup-inactive-users — bulk-delete inactive users (supports dry_run=true)
- and similar cleanup operations
Not for general API access¶
The admin token does not authorize any of the regular /threads, /assistants, /runs, etc. endpoints.
How requests are authorized¶
Most endpoints (everything that uses AuthedUser as a FastAPI dependency) require:
Authorization: Bearer <epsimoai-jwt>
The handler in app/auth/handlers.py:
1. Extracts the bearer token.
2. Verifies signature, iss, aud, exp against JWT_DECODE_KEY_B64 (or JWT_SECRET_KEY) using JWT_ALGORITHM (HS256 by default).
3. Loads the user record from the DB by user_id. Creates one if missing (legacy behaviour to support imported tokens).
4. Adds project_id from the token to the user object.
5. Passes the resulting User object to the route handler.
If the token is missing, malformed, expired, or signed with the wrong key, the handler returns 401 Unauthorized.
Server-side env vars driving JWT validation¶
| Env var | Purpose |
|---|---|
JWT_ALGORITHM (or JWT_ALG) |
Signing algorithm. Default HS256. |
JWT_ISSUER (or JWT_ISS) |
Required issuer claim. |
JWT_AUDIENCE (or JWT_AUD) |
Required audience claim. |
JWT_SECRET_KEY |
HS256 signing key (also used as decode key for symmetric algorithms). |
JWT_DECODE_KEY_B64 |
Base64-encoded decode key (asymmetric or symmetric). Takes precedence over JWT_SECRET_KEY. |
The same key is used for signing tokens minted by /auth/login, /auth/google/callback, and /projects/{id}.
Public vs authenticated endpoints¶
| Path prefix | Auth required? | Notes |
|---|---|---|
/ok |
No | Health check. |
/auth/login, /auth/signup, /auth/verify-email, /auth/google/*, /auth/exchange |
No | Auth flows themselves don't need an existing token. |
/auth/thread-info, /auth/me, etc. |
Yes | User-scoped. |
/projects/* |
Yes | User JWT required. |
/assistants/*, /threads/*, /runs/*, /user/*, /tools/* |
Yes | User JWT required. |
/public/runs/stream, /public/openai/v1/chat/completions |
Per-assistant | Authorization governed by the assistant's access_mode config. |
/admin/* |
Admin token (query param) | NOT JWT. |
/checkout/*, /price/* |
Yes (most) | Stripe-related. |
CORS¶
The backend allows credentialed requests from a fixed list of origins. Configured via:
- Default list in
app/server.py(currently includeshttps://chat.epsimoagents.com,https://app.epsimoagents.com, localhost ports, etc.) - Or override entirely via the
CORS_ORIGINSenv var (comma-separated list).
If you call the API from a browser-based app on a domain not in this list, the request will be blocked. Add the origin to CORS_ORIGINS in the ECS task definition (or to the default list in server.py if you want it baked into the image).
CLI quick-start recipes¶
Recipe A — Personal CLI access (simplest)¶
# 1) One-time login (or do this when token expires every 30 days)
JWT=$(curl -s -X POST https://backend.epsimoagents.com/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"..."}' \
| jq -r .jwt_token)
# 2) Save it
echo "$JWT" > ~/.epsimo-jwt
# 3) Use it in scripts
JWT=$(cat ~/.epsimo-jwt)
curl https://backend.epsimoagents.com/threads -H "Authorization: Bearer $JWT"
Recipe B — CLI scoped to a single project¶
JWT=$(cat ~/.epsimo-jwt)
# List your projects
curl -s https://backend.epsimoagents.com/projects/ \
-H "Authorization: Bearer $JWT" | jq
# Get a project-scoped token (replace <project-id>)
PROJECT_JWT=$(curl -s "https://backend.epsimoagents.com/projects/<project-id>" \
-H "Authorization: Bearer $JWT" | jq -r .jwt_token)
# Use the project-scoped token from now on
curl https://backend.epsimoagents.com/threads \
-H "Authorization: Bearer $PROJECT_JWT"
Recipe C — Embedding an assistant in an external platform¶
- In the frontend, pick or create an assistant.
- Edit its config: set
access_mode = public_tokenandpublic_token = <generate-a-secret>. - In the external platform, configure it like an OpenAI integration:
- Base URL:
https://backend.epsimoagents.com/public/openai/v1 - API Key: the secret you set
- The platform will call
POST /chat/completionsand your assistant responds.
Recipe D — Cross-app SSO via Cognito¶
(Requires server-side config — see Section 5.)
# Your other app authenticates the user against Cognito and obtains an ID token.
COGNITO_TOKEN="eyJ..."
# Exchange for an EpsimoAI token
EPSIMO_JWT=$(curl -s -X POST https://backend.epsimoagents.com/auth/exchange \
-H "Authorization: Bearer $COGNITO_TOKEN" \
| jq -r .token)
# Use it for 1 hour
curl https://backend.epsimoagents.com/threads \
-H "Authorization: Bearer $EPSIMO_JWT"
Token lifetimes summary¶
| Token type | Algorithm | Lifetime | Renewable? |
|---|---|---|---|
/auth/login JWT |
HS256 | 30 days | Re-login |
| Google callback JWT | HS256 | 30 days | Re-login |
/projects/{id} JWT |
HS256 | 30 days | Re-call the endpoint |
/auth/exchange JWT |
RS256 | 1 hour | Re-exchange a fresh Cognito token |
| Per-assistant public token | n/a (raw secret) | Until rotated in config | Edit the assistant |
| Admin token | n/a (raw secret) | Until env var is rotated | Update env var, redeploy |
Security considerations¶
Secrets storage¶
- All secret env vars (
GOOGLE_OAUTH_SECRET_ID,JWT_SECRET_KEY,EPSIMOAI_JWT_PRIVATE_KEY,ADMIN_TOKEN, etc.) must be stored in AWS Secrets Manager and referenced from the ECS task definition via thesecretsfield. Never put them inenvironment(they show up in plaintext inaws ecs describe-task-definition). - Never log secret values. The startup logging in
app/api/authentication.pywas deliberately scrubbed to avoid printing the OAuth client secret to CloudWatch.
JWT key rotation¶
Rotating JWT_SECRET_KEY / JWT_DECODE_KEY_B64 invalidates every outstanding token (login, Google, project-scoped). All users will be forced to log in again. This is currently the only revocation mechanism.
Token leakage¶
A leaked EpsimoAI JWT is valid for the rest of its lifetime (up to 30 days for login tokens, 1 hour for Cognito-exchange tokens) and cannot be selectively revoked. Mitigation:
- Don't store JWTs in browser localStorage for high-value applications. Use httpOnly cookies if possible.
- Don't print JWTs to logs.
- Don't put JWTs in URLs (the Google callback intentionally puts the JWT in a URL because that's the only way to get it from the OAuth callback to the SPA — the SPA should remove it from window.location immediately).
CORS¶
allow_credentials=True plus a wildcard origin would be a CSRF risk. The backend correctly uses an explicit allowlist.
Gaps / future work¶
The following are NOT supported today and would require new code:
-
Long-lived per-user API keys. A separate
api_keysDB table, endpoints to create/list/revoke, and middleware to acceptAuthorization: Bearer epsimo_<...>alongside JWTs. Useful for service accounts, CI integrations, and personal CLIs that don't want to re-login every 30 days. -
Refresh tokens. Currently the only "renewal" is to re-login or re-exchange. A refresh-token flow would let clients swap a short-lived access token for a new one without re-auth.
-
JWT revocation. Stateful revocation (e.g. a denylist or a token-version column on the user row) so individual leaked tokens can be killed without rotating the entire signing key.
-
Cognito environment wiring. The
/auth/exchangeendpoint is implemented but the production env vars for it are not set, and the auth handler doesn't currently accept the RS256 tokens it would mint. -
Per-token quotas / rate limits for public assistants.
-
Multi-Factor Authentication.
-
Session listing / revocation UI. "Show me where I'm logged in, log out everywhere."
If you need any of these, they're real feature builds — happy to scope them.
File map (where things live in the code)¶
| File | Responsibility |
|---|---|
app/api/authentication.py |
/auth/login, /auth/signup, /auth/verify-email, /auth/google/*, /auth/exchange, /auth/thread-info |
app/api/projects.py |
/projects/* including the project-scoped JWT endpoint |
app/api/public_runs.py |
/public/runs/* and /public/openai/v1/* (per-assistant public token logic) |
app/api/openai_v1.py |
OpenAI-compatible wrapper around public_runs |
app/api/admin_cleanup.py |
/admin/* endpoints (admin-token-protected) |
app/auth/handlers.py |
JWT validation middleware (AuthedUser dependency) |
app/auth/settings.py |
Loads JWT config from env |
Last updated: 2026-05-20. Verified against backend revision deployed at https://backend.epsimoagents.com (ECS task definition default-epsimoai-backend-604b:16).