| title | description |
|---|---|
Security |
Security architecture, authentication tiers, and hardening measures for the Sentinel Auth |
This document describes the security architecture of the Sentinel Auth, covering transport security, authentication mechanisms, token lifecycle, service-to-service auth, and input validation.
The service applies middleware in a specific order (outermost first):
Request
|
v
GlobalRateLimitMiddleware -- 30 req/min per IP (all endpoints)
|
v
SecurityHeadersMiddleware -- security response headers
|
v
SessionMiddleware -- encrypted session for OAuth2 state
|
v
TrustedHostMiddleware -- Host header validation (production)
|
v
CORSMiddleware -- cross-origin request policy
|
v
Rate Limiting (slowapi) -- per-endpoint request throttling
|
v
Application Routes
Every response includes the following headers, set by SecurityHeadersMiddleware:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options |
nosniff |
Prevents MIME type sniffing |
X-Frame-Options |
DENY |
Blocks clickjacking via iframes |
Referrer-Policy |
strict-origin-when-cross-origin |
Limits referrer leakage |
X-XSS-Protection |
0 |
Disables legacy XSS filter (modern CSP preferred) |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Restricts browser APIs |
Content-Security-Policy |
default-src 'none'; frame-ancestors 'none' |
Blocks all resource loading and framing |
Cross-Origin-Embedder-Policy |
require-corp |
Prevents cross-origin resource leaks |
Cross-Origin-Opener-Policy |
same-origin |
Isolates browsing context |
Cross-Origin-Resource-Policy |
same-origin |
Restricts resource sharing to same origin |
X-Permitted-Cross-Domain-Policies |
none |
Blocks Flash/PDF cross-domain access |
Server |
daikon |
Masks underlying server technology |
When COOKIE_SECURE=true (production with HTTPS), the service adds:
Strict-Transport-Security: max-age=63072000; includeSubDomains
This enforces HTTPS for two years and covers all subdomains. Only enable this when your deployment is fully behind TLS.
Starlette's SessionMiddleware provides encrypted, signed cookies used exclusively by Authlib during the OAuth2 authorization code flow. The session stores the state parameter and PKCE code_verifier between the redirect and callback steps.
Configuration:
# Generate a strong secret (required in production)
python -c "import secrets; print(secrets.token_urlsafe(32))"SESSION_SECRET_KEY=your-generated-secret-hereThe default value dev-only-change-me-in-production is intentionally weak and must be replaced before deployment.
When ALLOWED_HOSTS is set to anything other than *, Starlette's TrustedHostMiddleware validates the Host header on every request. This prevents Host header injection attacks used in cache poisoning and password reset exploits.
# Development (disabled)
ALLOWED_HOSTS=*
# Production
ALLOWED_HOSTS=identity.example.com,api.example.comCross-Origin Resource Sharing is handled by DynamicCORSMiddleware, which combines static and database-backed origins:
- Static origins from the
CORS_ORIGINSenvironment variable - Dynamic origins extracted from
client_apps.redirect_urisin the database — the middleware derives the origin (scheme://host[:port]) from each registered redirect URI
Origins are refreshed from the database on startup.
CORS_ORIGINS=https://app.example.com,https://admin.example.comThe CORS policy allows:
- Origins: Static origins from
CORS_ORIGINS+ origins derived from registered client app redirect URIs (no wildcards in production) - Credentials: Enabled (
allow_credentials=True) for cookie-based admin auth - Methods:
GET,POST,PUT,PATCH,DELETE,OPTIONS - Headers:
Content-Type,Authorization,X-Service-Key
The service uses four authentication tiers, applied depending on the sensitivity and audience of each endpoint:
| Tier | Mechanism | Use Case | Example Endpoints |
|---|---|---|---|
| User JWT | Authorization: Bearer <token> |
End-user actions scoped to a workspace | /users/me, /workspaces, /groups |
| Service Key + User JWT | X-Service-Key header + Authorization: Bearer |
Service acting on behalf of a user | /permissions/check, /permissions/accessible, POST /permissions/{id}/share |
| Service Key Only | X-Service-Key header |
Autonomous service operations | /permissions/register, /permissions/visibility, DELETE /permissions/{id}/share |
| Admin Cookie | admin_token HttpOnly cookie |
Admin panel operations | /auth/admin/me, /admin/* |
All tokens are signed with RS256 (RSA + SHA-256) using a private key and verified with the corresponding public key. This allows any service with the public key to validate tokens without contacting the identity service.
Key generation:
openssl genrsa -out keys/private.pem 2048
openssl rsa -in keys/private.pem -pubout -out keys/public.pemAccess token claims:
| Claim | Type | Description |
|---|---|---|
sub |
UUID | User ID |
jti |
UUID | Unique token identifier (for revocation) |
email |
string | User email |
name |
string | User display name |
wid |
UUID | Workspace ID |
wslug |
string | Workspace slug |
wrole |
string | Workspace role (owner, admin, editor, viewer) |
groups |
UUID[] | Group IDs the user belongs to in this workspace |
type |
string | "access" |
iat |
timestamp | Issued at |
exp |
timestamp | Expiration (default: 15 minutes) |
Admin token claims:
| Claim | Type | Description |
|---|---|---|
sub |
UUID | User ID |
jti |
UUID | Unique token identifier (for revocation) |
email |
string | User email |
name |
string | User display name |
admin |
boolean | Always true |
type |
string | "admin_access" |
iat |
timestamp | Issued at |
exp |
timestamp | Expiration (default: 1 hour) |
Token lifetimes:
| Token | Default Lifetime | Configurable Via |
|---|---|---|
| Access token | 15 minutes | ACCESS_TOKEN_EXPIRE_MINUTES |
| Refresh token | 7 days | REFRESH_TOKEN_EXPIRE_DAYS |
| Admin token | 1 hour | ADMIN_TOKEN_EXPIRE_MINUTES |
The service implements refresh token rotation with reuse detection, modeled after the approach described in Auth0's documentation:
-
Issuance: When a user authenticates, the service issues an access token and a refresh token. The refresh token's
jtiis stored in Redis along with afamily_id. -
Rotation: When the client presents a refresh token at
POST /auth/refresh, the service:- Atomically consumes the token (
GETDELin Redis -- one-time use) - Issues a new access + refresh token pair
- Stores the new refresh token in the same family
- Atomically consumes the token (
-
Reuse detection: If a consumed refresh token is presented again, the service rejects it. This signals potential token theft -- an attacker replaying a stolen token after the legitimate client already rotated it.
-
Family revocation: When theft is detected or a user is deactivated, the service revokes the entire token family by deleting all
jtientries in the family set.
Redis key structure:
| Key Pattern | Value | TTL |
|---|---|---|
rt:{jti} |
{user_id}:{family_id} |
REFRESH_TOKEN_EXPIRE_DAYS |
rtf:{family_id} |
Set of jti values |
REFRESH_TOKEN_EXPIRE_DAYS |
After a successful OAuth callback, the service issues a short-lived authorization code instead of passing the raw user_id in the redirect URL. This prevents token theft by anyone who knows a user's UUID.
PKCE is mandatory on Sentinel's own auth codes (S256 only). The frontend must generate a code_verifier and code_challenge before initiating login, pass the code_challenge on GET /auth/login/{provider}, and include the code_verifier when exchanging the code at POST /auth/token. This binds the auth code exchange to the original initiator, preventing authorization code interception attacks.
- The frontend sends
code_challengeandcode_challenge_method=S256as query params on the login endpoint - The callback generates a cryptographically random code and stores it in Redis with a 5-minute TTL (alongside the
code_challenge) - The client uses the code to fetch workspaces (
GET /auth/workspaces?code=X) — this peeks at the code without consuming it - The client exchanges the code for tokens (
POST /auth/tokenwith{code, workspace_id, code_verifier}) — Sentinel verifiesSHA256(code_verifier) == code_challenge, then consumes the code atomically viaGETDEL - A consumed code cannot be reused; a second exchange attempt returns
400
Redis key structure:
| Key Pattern | Value | TTL |
|---|---|---|
ac:{code} |
JSON {user_id, code_challenge, code_challenge_method} |
5 minutes |
Access tokens can be revoked before expiration (e.g., on logout) using a Redis denylist:
- Client calls
POST /auth/logoutwith the access token in theAuthorizationheader - The service extracts the
jtiandexpfrom the token - The
jtiis added to the denylist with a TTL equal to the token's remaining lifetime - On every authenticated request, the
get_current_userdependency checks the denylist
Redis key structure:
| Key Pattern | Value | TTL |
|---|---|---|
bl:{jti} |
"1" |
Remaining seconds until token expiration |
This approach keeps the denylist small -- entries automatically expire when the token would have expired anyway.
Admin tokens follow the same denylist pattern as access tokens. When an admin logs out via POST /auth/admin/logout:
- The endpoint requires a valid admin cookie (
Depends(require_admin)) - The
jtifrom the admin token is added to the Redis denylist - The
admin_tokencookie is deleted from the response - On subsequent requests,
require_adminchecks the denylist before granting access
User logout (POST /auth/logout) performs two actions:
- Blacklists the access token — adds
jtito the Redis denylist - Revokes all refresh token families — calls
revoke_all_user_tokens(user_id)to invalidate every refresh token the user has, preventing an attacker with a captured refresh token from obtaining new access tokens after the user logs out
Backend services authenticate to the identity service using the X-Service-Key header. This is used for permission and role operations where a service acts autonomously or on behalf of a user.
Service API keys are managed through the service apps system in the admin panel (/admin/service-apps), not environment variables. Each service app has:
name— human-readable labelservice_name— the service this key is scoped to (verified byverify_service_scope())key_hash— SHA-256 hash of the plaintext key (the plaintext is shown once at creation)key_prefix— first few characters for identification (e.g.,sk_abc1****)is_active— can be deactivated without deletion
Keys are validated by service_app_service.validate_key(), which checks the SHA-256 hash against active service apps (with Redis caching).
| Service Apps in DB | Request Without Key | Request With Invalid Key | Request With Valid Key |
|---|---|---|---|
| None active (dev mode) | Allowed | Allowed | Allowed |
| At least one active (production) | 401 Unauthorized | 401 Unauthorized | Allowed |
Dev mode is intentionally permissive: when no active service apps exist in the database, the require_service_key dependency passes through all requests. This allows local development without configuring keys. In production, register at least one service app via the admin panel.
Some endpoints require both a service key and a user JWT. This pattern is used when a service needs to perform an action on behalf of a specific user -- the service key authenticates the calling service, and the JWT identifies the user:
POST /permissions/check
X-Service-Key: sk_prod_abc123
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
The admin panel uses HttpOnly cookies for session management. Cookie attributes are configured for defense in depth:
| Attribute | Value | Purpose |
|---|---|---|
httponly |
True |
Prevents JavaScript access (XSS mitigation) |
samesite |
strict |
Blocks cross-site request inclusion (CSRF mitigation) |
secure |
COOKIE_SECURE setting |
Restricts to HTTPS when enabled |
max_age |
3600 (1 hour) |
Cookie expires after 1 hour |
path |
/ |
Available across all routes |
# Development (HTTP)
COOKIE_SECURE=false
# Production (HTTPS)
COOKIE_SECURE=trueWhen COOKIE_SECURE=true, the Secure flag ensures the cookie is only sent over HTTPS connections. Additionally, this flag enables HSTS headers on all responses.
Rate limiting uses two layers:
-
Global rate limit —
GlobalRateLimitMiddlewareenforces 30 requests/minute per IP across all endpoints (except/health). This is a simple in-memory sliding window that catches broad abuse regardless of endpoint. -
Per-endpoint limits — slowapi applies stricter limits on sensitive endpoints. These fire before the global limit.
When a client exceeds either limit, the service responds with 429 Too Many Requests and a Retry-After header.
| Endpoint | Limit | Rationale |
|---|---|---|
| All endpoints | 30/minute (global) | Baseline abuse prevention |
GET /auth/login/{provider} |
10/minute | Prevents OAuth redirect abuse |
GET /auth/callback/{provider} |
10/minute | Limits callback processing |
GET /auth/workspaces |
10/minute | Limits workspace listing during auth |
POST /auth/token |
10/minute | Prevents auth code brute-force |
POST /auth/refresh |
10/minute | Prevents refresh token brute-force |
GET /auth/admin/login/{provider} |
5/minute | Stricter limit on admin login |
GET /auth/admin/callback/{provider} |
5/minute | Stricter limit on admin callback |
Rate limit state is keyed by the client's remote IP address. If the service is behind a reverse proxy, ensure X-Forwarded-For is configured correctly so the real client IP is used.
PKCE prevents authorization code interception attacks. The service uses S256 (SHA-256) code challenge method on providers that support it:
| Provider | PKCE | Method | Notes |
|---|---|---|---|
| Yes | S256 | Full OIDC with openid email profile scope |
|
| Microsoft EntraID | Yes | S256 | Full OIDC with openid email profile scope |
| GitHub | No | N/A | GitHub does not support PKCE as of 2025; relies on state parameter |
PKCE is configured at the Authlib client registration level via code_challenge_method="S256". Authlib automatically generates the code_verifier and code_challenge, storing the verifier in the session for validation during the callback.
Applications must be registered as client apps before they can use Sentinel. Each client app defines a set of allowed redirect URIs. Sentinel proxies authentication from external IdPs and validates that the redirect_uri belongs to an active registered app.
GET /auth/login/{provider}requires aredirect_urithat is registered on an active client app- Only pre-approved redirect URIs can receive authorization codes
This prevents:
- Unauthorized usage — unregistered applications cannot initiate login flows or obtain tokens
- Open redirector attacks — the callback can only redirect to pre-approved URIs
Client apps can be deactivated without deletion to temporarily block an application.
All OAuth2 flows use the state parameter (managed by Authlib via SessionMiddleware) to prevent CSRF attacks during the authorization code exchange. The state is generated on redirect, stored in the encrypted session cookie, and validated on callback.
All request bodies are validated with Pydantic models. Invalid input is rejected with a 422 Unprocessable Entity response before reaching any business logic. This includes:
- Type checking and coercion
- UUID format validation
- Enum value constraints (e.g., workspace roles, permission actions)
- Required vs. optional field enforcement
CSV import endpoints (used by the admin panel) enforce a 5 MB file size limit to prevent denial-of-service via large uploads.
All security-related environment variables:
| Variable | Default | Description |
|---|---|---|
SESSION_SECRET_KEY |
dev-only-change-me-in-production |
Secret for signing session cookies (OAuth2 state) |
COOKIE_SECURE |
false |
Set true in production to enable Secure flag and HSTS |
ALLOWED_HOSTS |
"" (empty) |
Derived from BASE_URL + ADMIN_URL hostnames. Falls back to ["*"] only if no hostnames found. |
CORS_ORIGINS |
http://localhost:3000,http://localhost:9101 |
Comma-separated static CORS origins (combined with DB client app origins at runtime) |
JWT_PRIVATE_KEY_PATH |
keys/private.pem |
Path to RS256 private key for signing tokens |
JWT_PUBLIC_KEY_PATH |
keys/public.pem |
Path to RS256 public key for verifying tokens |
ACCESS_TOKEN_EXPIRE_MINUTES |
15 |
Access token lifetime in minutes |
REFRESH_TOKEN_EXPIRE_DAYS |
7 |
Refresh token lifetime in days |
ADMIN_TOKEN_EXPIRE_MINUTES |
60 |
Admin token lifetime in minutes |
DEBUG |
true |
Set false in production to disable /docs, /redoc, /openapi.json |
ADMIN_EMAILS |
(empty) | Comma-separated emails auto-promoted to admin on login |
The pentest/ directory contains a comprehensive security testing suite combining industry-standard tools with custom scripts.
# Install tools (one-time)
make pentest-setup
# Run everything
make pentest
# Custom scripts only (no external tools)
make pentest-custom
# Single tool
cd pentest && python run_all.py --nuclei| Tool | What It Tests |
|---|---|
| OWASP ZAP | API scanning via OpenAPI spec — injection, auth bypass, misconfigurations |
| Nuclei | Template-based vulnerability and misconfiguration detection |
| Nikto | Web server misconfiguration, default files, header issues |
| jwt_tool | JWT-specific attacks — algorithm confusion, none bypass, claim injection |
Ten test suites covering ~110 individual tests:
| Suite | Coverage |
|---|---|
| JWT Attacks | Algorithm confusion, token forgery, claim tampering, JWK/KID injection |
| Admin Bypass | Cookie theft/replay, privilege escalation, token revocation |
| IDOR & AuthZ | Cross-workspace access, resource ID enumeration, role bypass |
| Service Key | Dev-mode bypass, key brute-force, missing enforcement |
| Rate Limiting | Header spoofing, endpoint flooding, evasion techniques |
| Injection & XSS | SQL injection, stored XSS, CSV injection, path traversal |
| Session & OAuth | Session fixation, state tampering, CSRF, redirect manipulation |
| Info Disclosure | OpenAPI exposure, error verbosity, header leakage |
| Token Lifecycle | Refresh rotation abuse, reuse detection, logout bypass |
| Attack Chains | End-to-end scenarios chaining multiple vulnerabilities |
All output is saved to pentest/reports/:
summary.json— combined results from all tools and custom scriptszap_report.json,nuclei_findings.jsonl,nikto_report.json,jwt_tool_results.txt
Before deploying to production, verify the following:
-
SESSION_SECRET_KEYis set to a cryptographically random value (not the default) - At least one service app is registered via the admin panel (
/admin/service-apps) with a strong key -
COOKIE_SECURE=trueand the service is behind TLS -
ALLOWED_HOSTSis set to your actual domain(s), not* -
CORS_ORIGINSlists only your frontend origin(s) - RS256 key pair is generated and
JWT_PRIVATE_KEY_PATH/JWT_PUBLIC_KEY_PATHpoint to the correct files - The private key file has restrictive permissions (
chmod 600) -
DEBUG=falseto disable OpenAPI docs (/docs,/redoc,/openapi.json) -
ADMIN_EMAILSis set if you want auto-promotion for specific users - A reverse proxy (nginx, Caddy, or cloud LB) handles TLS termination and sets
X-Forwarded-For - Redis is password-protected and not exposed to the public internet
- PostgreSQL uses strong credentials and is not exposed to the public internet