A Python auth library that handles JWT token management, password hashing, OAuth2 login flow, and route protection — so you don't have to wire it together yourself.
Project description
A Python auth library that handles JWT token management, password hashing, OAuth2 login flow, and route protection — so you don't have to wire it together yourself.
Most auth libraries do one thing. PyJWT gives you JWT encoding. bcrypt gives you password hashing. You still have to write the login flow, build the guards, handle the exceptions, and repeat that boilerplate across every project. gatevault wraps all of it into one coherent package with a clean API you can drop into any Python project regardless of framework.
pip install richard-gatevault
Table of Contents
- Installation
- The Full Picture
- Password Hashing
- Token Management
- Login Flow
- Protecting Routes
- Exception Handling
- Warnings
- Framework Integration
- Using gatevault in Parts
- Token Refresh & Rotation
- Security Guide
- API Reference
- Design Decisions
- Known Limitations
- Future Improvements
- Contributing
- License
Installation
pip install richard-gatevault
Requires Python 3.9+. Dependencies PyJWT and bcrypt are installed automatically.
Everything in gatevault is importable from the top level:
from gatevault import (
TokenManager,
OAuthHandler,
GateVault,
hash_password,
verify_password,
)
The Full Picture
Here is what a complete auth setup looks like end to end — registration, login, token storage, protected routes, and token refresh. If you only need one specific feature, jump to the relevant section.
import os
from gatevault import (
TokenManager, OAuthHandler, GateVault,
hash_password, verify_password,
InvalidCredentialsError, UnauthorizedError,
TokenExpiredError, GuardError
)
# ---------------------------------------------------------------------------
# Setup — do this once at app startup
# ---------------------------------------------------------------------------
tm = TokenManager(
secret_key=os.environ["AUTH_SECRET_KEY"],
access_expiry_minutes=15,
refresh_expiry_days=7
)
gate = GateVault(token_manager=tm)
oauth = OAuthHandler(token_manager=tm, get_user=get_user_from_db)
# ---------------------------------------------------------------------------
# Registration — hash and store the password
# ---------------------------------------------------------------------------
def register(username: str, plain_password: str):
hashed = hash_password(plain_password)
db.create_user(username=username, hashed_password=hashed)
return {"message": "registered"}
# ---------------------------------------------------------------------------
# Login — returns access token in body, refresh token goes in httpOnly cookie
#
# After login:
# - The server sets the refresh token as an httpOnly cookie automatically.
# The browser stores it and sends it on every request to /refresh.
# - The access token is returned in the response body.
# The CLIENT is responsible for storing it (in a JS variable, not localStorage)
# and sending it in the Authorization header on every protected request:
#
# Authorization: Bearer <access_token>
#
# Client-side example (JavaScript):
# const { access_token } = await fetch("/login", { method: "POST", ... }).then(r => r.json())
# // store in memory — not localStorage
# // then on every protected request:
# await fetch("/profile", { headers: { "Authorization": `Bearer ${access_token}` } })
# ---------------------------------------------------------------------------
def login(username: str, password: str):
try:
tokens = oauth.login(username, password)
except (InvalidCredentialsError, UnauthorizedError):
return {"error": "invalid credentials"}, 401
# In a real app, set the refresh token as an httpOnly cookie:
# response.set_cookie("refresh_token", tokens["refresh_token"], httponly=True)
#
# Return only the access token — the client stores and manages it:
return {"access_token": tokens["access_token"]}
# ---------------------------------------------------------------------------
# Protected functions — decorated once, reused across many routes
# ---------------------------------------------------------------------------
@gate.protected
def get_profile(payload=None):
user_id = payload["user_id"]
return db.get_user(user_id)
@gate.protected
def get_orders(payload=None):
user_id = payload["user_id"]
return db.get_orders(user_id)
@gate.protected
def update_settings(settings: dict, payload=None):
user_id = payload["user_id"]
return db.update_settings(user_id, settings)
# ---------------------------------------------------------------------------
# Routes — the client sends the access token in the Authorization header.
# The framework gives your route access to that header.
# You extract the token and pass it to the protected function.
# gatevault verifies it and injects the payload.
#
# Client sends on every request: Authorization: Bearer <access_token>
# ---------------------------------------------------------------------------
def profile_route(authorization_header: str):
token = authorization_header.replace("Bearer ", "")
try:
return get_profile(token=token)
except (GuardError, UnauthorizedError):
return {"error": "unauthorized"}, 401
def orders_route(authorization_header: str):
token = authorization_header.replace("Bearer ", "")
try:
return get_orders(token=token)
except (GuardError, UnauthorizedError):
return {"error": "unauthorized"}, 401
# ---------------------------------------------------------------------------
# Token refresh — client sends the refresh token (from cookie)
# Server issues a new access token and rotates the refresh token
# ---------------------------------------------------------------------------
def refresh_route(refresh_token: str):
try:
payload = tm.decode_token(refresh_token)
except TokenExpiredError:
return {"error": "session expired, please log in again"}, 401
except (GuardError, UnauthorizedError):
return {"error": "invalid token"}, 401
if payload["type"] != "refresh":
return {"error": "wrong token type"}, 400
# Invalidate old refresh token in your DB before issuing a new one
db.revoke_refresh_token(refresh_token)
new_access = tm.create_access_token(user_id=payload["user_id"])
new_refresh = tm.create_refresh_token(user_id=payload["user_id"])
db.store_refresh_token(new_refresh, user_id=payload["user_id"])
# set new_refresh as httpOnly cookie in a real app
return {"access_token": new_access}
Password Hashing
gatevault uses bcrypt for password hashing. Passwords are one-way hashed — there is no way to reverse a hash back to the original password. If your database is ever compromised, attackers get hashes, not passwords.
Hashing a password
from gatevault import hash_password
hashed = hash_password("user_plain_password")
print(hashed)
# $2b$12$Kq8J3mNrandom...
Always hash at the point of registration and store the result. Never store or log the plain password.
def register(username: str, plain_password: str):
hashed = hash_password(plain_password)
db.insert(username=username, hashed_password=hashed)
return {"message": "account created"}
Verifying a password
from gatevault import verify_password
is_match = verify_password("user_plain_password", stored_hash)
# True or False
verify_password returns a boolean. It never raises on a wrong password — it just returns False. What you do with that result is your decision.
from gatevault import verify_password, InvalidCredentialsError, UnauthorizedError
def authenticate(username: str, plain_password: str):
user = db.get_user(username)
if not user:
raise InvalidCredentialsError("user not found")
if not verify_password(plain_password, user.hashed_password):
raise UnauthorizedError("wrong password")
return user
About bcrypt salting
You do not need to manage salts yourself. bcrypt generates a unique random salt for every hash and embeds it in the output string. Two calls to hash_password with the same password produce different hashes — both are valid.
h1 = hash_password("same_password")
h2 = hash_password("same_password")
print(h1 == h2) # False — different salts
print(verify_password("same_password", h1)) # True
print(verify_password("same_password", h2)) # True
print(verify_password("wrong", h1)) # False
Standalone usage
hash_password and verify_password have no dependency on the rest of gatevault. You can use them without setting up TokenManager or anything else:
from gatevault import hash_password, verify_password
# registration
stored = hash_password("mypassword")
# login check
if verify_password("mypassword", stored):
print("access granted")
else:
print("access denied")
Token Management
TokenManager handles all JWT creation and verification. It is the core of gatevault. Create one instance at startup and share it across your app.
Setup
import os
from gatevault import TokenManager
tm = TokenManager(
secret_key=os.environ["AUTH_SECRET_KEY"],
access_expiry_minutes=15,
refresh_expiry_days=7
)
The secret_key is what signs your tokens. Anyone with this key can forge valid tokens — keep it in an environment variable, never in source code.
To generate a secure key:
python -c "import secrets; print(secrets.token_hex(32))"
Creating tokens
access_token = tm.create_access_token(user_id=42)
refresh_token = tm.create_refresh_token(user_id=42)
print(access_token)
# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Access tokens are short-lived — meant to be sent with every authenticated request. Refresh tokens are long-lived — used only to get a new access token when the current one expires.
Extra claims
You can embed additional data in the token payload using keyword arguments:
# embed role and org at login time
access_token = tm.create_access_token(user_id=42, role="admin", org_id=7)
payload = tm.decode_token(access_token)
print(payload)
# {
# "user_id": 42,
# "exp": 1234567890,
# "type": "access",
# "role": "admin",
# "org_id": 7
# }
This means your guards can make role-based decisions without hitting the database on every request:
@gate.protected
def admin_dashboard(payload=None):
if payload.get("role") != "admin":
raise UnauthorizedError("admin access required")
return get_admin_data()
Decoding tokens
payload = tm.decode_token(token)
user_id = payload["user_id"]
token_type = payload["type"] # "access" or "refresh"
expiry = payload["exp"] # unix timestamp
decode_token verifies the signature and checks expiry in one call. It raises specific exceptions on failure:
from gatevault import TokenExpiredError, InvalidTokenError, TokenDecodeError
try:
payload = tm.decode_token(token)
except TokenExpiredError:
# token has passed its exp time — send client to refresh endpoint
return {"error": "token expired"}, 401
except InvalidTokenError:
# signature mismatch — token was tampered with
return {"error": "invalid token"}, 401
except TokenDecodeError:
# token string is malformed — can't be parsed
return {"error": "malformed token"}, 400
Access vs refresh — telling them apart
Every token carries a type claim. Check it when you need to enforce which kind of token is being used:
payload = tm.decode_token(token)
if payload["type"] != "access":
raise UnauthorizedError("expected an access token, got refresh")
This matters on your refresh endpoint — you only want refresh tokens there, not access tokens:
def refresh_route(token: str):
payload = tm.decode_token(token)
if payload["type"] != "refresh":
return {"error": "wrong token type"}, 400
# proceed with issuing new tokens
TokenManager is shared
OAuthHandler creates tokens using TokenManager. GateVault verifies tokens using the same TokenManager. They share the same secret key. Create one instance and pass it to both:
tm = TokenManager(secret_key=os.environ["AUTH_SECRET_KEY"], access_expiry_minutes=15, refresh_expiry_days=7)
oauth = OAuthHandler(token_manager=tm, get_user=get_user) # uses tm to create tokens
gate = GateVault(token_manager=tm) # uses tm to verify tokens
Login Flow
OAuthHandler wires together user lookup, password verification, and token creation into one call. It follows the OAuth2 Resource Owner Password Credentials flow.
Setup
from gatevault import OAuthHandler
def get_user(username: str):
# return a user object with `id` and `hashed_password` attributes
# return None if the user doesn't exist
return db.query(User).filter(User.email == username).first()
oauth = OAuthHandler(token_manager=tm, get_user=get_user)
Your get_user function must return an object with two attributes:
id— the user's identifier, embedded in the token payloadhashed_password— the bcrypt hash stored at registration
If your model uses different field names, add a property:
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String)
password_hash = Column(String) # your field is called password_hash
@property
def hashed_password(self):
return self.password_hash # gatevault expects hashed_password
What login does
tokens = oauth.login("john@example.com", "their_password")
print(tokens)
# {
# "access_token": "eyJhbGci...",
# "refresh_token": "eyJhbGci...",
# "token_type": "bearer"
# }
login does three things in order:
- Calls
get_user(username)— raisesInvalidCredentialsErrorifNoneis returned - Calls
verify_password(password, user.hashed_password)— raisesUnauthorizedErrorif it returnsFalse - Calls
create_access_tokenandcreate_refresh_token— raisesGuardErrorif token creation fails
What to do with the tokens
The access token goes back to the client in the response body. The refresh token should be set as an httpOnly cookie — it cannot be read by JavaScript:
# pseudocode — adjust to your framework
def login_route(username, password, response):
try:
tokens = oauth.login(username, password)
except (InvalidCredentialsError, UnauthorizedError):
return {"error": "invalid credentials"}, 401
# refresh token goes in a secure httpOnly cookie
response.set_cookie(
key="refresh_token",
value=tokens["refresh_token"],
httponly=True,
secure=True, # HTTPS only
samesite="strict"
)
# access token goes in the response body — client stores in memory
return {"access_token": tokens["access_token"]}
The server's job ends there. The client receives the access token in the response body and is responsible for storing it in memory (a JS variable, not localStorage). On every subsequent request, the client reads it from memory and puts it in the Authorization header manually:
Authorization: Bearer <access_token>
The refresh token is different — the browser stores and sends httpOnly cookies automatically, so the client never has to handle it directly.
Client-side example:
// After login — store access token in memory
const { access_token } = await fetch("/login", {
method: "POST",
body: JSON.stringify({ username, password })
}).then(r => r.json())
// On every protected request — send it in the Authorization header
const profile = await fetch("/profile", {
headers: { "Authorization": `Bearer ${access_token}` }
}).then(r => r.json())
// When access token expires — browser sends refresh cookie automatically
const refreshed = await fetch("/refresh", {
method: "POST",
credentials: "include" // sends the httpOnly cookie
}).then(r => r.json())
// Store the new access token
const new_access_token = refreshed.access_token
Handling login errors
from gatevault import InvalidCredentialsError, UnauthorizedError, GuardError
try:
tokens = oauth.login(username, password)
except InvalidCredentialsError:
# user not found
return {"error": "invalid credentials"}, 401
except UnauthorizedError:
# wrong password
return {"error": "invalid credentials"}, 401
except GuardError:
# token creation failed unexpectedly
return {"error": "authentication failed"}, 500
Return the same error message for both
InvalidCredentialsErrorandUnauthorizedError. Distinguishing between them tells an attacker which usernames are valid.
Protecting Routes
GateVault wraps any function with token verification. The wrapped function never executes if the token is missing, expired, or invalid. On success, the decoded payload is injected as the payload keyword argument.
Setup
from gatevault import GateVault
gate = GateVault(token_manager=tm)
Basic usage
@gate.protected
def get_profile(payload=None):
user_id = payload["user_id"]
return db.get_user(user_id)
The token is passed at call time via token=. In a real app, the token is not something you have directly in your server code — the client sends it in the Authorization header, your framework gives you access to that header, and you extract the token from it and pass it to the protected function:
# Client sent: Authorization: Bearer eyJhbGci...
# Framework gives you the header value — you strip "Bearer " and pass it in
def profile_route(authorization_header: str):
token = authorization_header.replace("Bearer ", "")
try:
return get_profile(token=token)
except (GuardError, UnauthorizedError):
return {"error": "unauthorized"}, 401
The client is responsible for storing the access token after login and attaching it to every request. gatevault only sees it at the moment you pass it to the protected function.
Multiple protected routes
Decorate each function once and reuse:
@gate.protected
def get_profile(payload=None):
return db.get_user(payload["user_id"])
@gate.protected
def get_orders(payload=None):
return db.get_orders(payload["user_id"])
@gate.protected
def get_delivery(order_id: int, payload=None):
return db.get_delivery(order_id, payload["user_id"])
@gate.protected
def update_settings(settings: dict, payload=None):
return db.update_settings(payload["user_id"], settings)
Each one is independently protected. The same access token works for all of them — the client sends the same Authorization header on every request, and each route passes it to its own protected function.
Passing arguments alongside the token
Your function can accept any arguments alongside payload:
@gate.protected
def get_post(post_id: int, payload=None):
user_id = payload["user_id"]
post = db.get_post(post_id)
if post.owner_id != user_id:
raise UnauthorizedError("not your post")
return post
# pass token and other args together
result = get_post(post_id=7, token="eyJhbGci...")
Role-based access using claims
Embed the role at token creation time:
tokens = oauth.login(username, password)
# internally: tm.create_access_token(user_id=user.id, role=user.role)
Check it in your protected function:
@gate.protected
def admin_only(payload=None):
if payload.get("role") != "admin":
raise UnauthorizedError("admin access required")
return get_admin_data()
@gate.protected
def moderator_or_above(payload=None):
if payload.get("role") not in ("admin", "moderator"):
raise UnauthorizedError("insufficient permissions")
return get_mod_tools()
Handling guard errors
from gatevault import GuardError, UnauthorizedError
try:
result = get_profile(token=incoming_token)
except GuardError as e:
# token missing, expired, or malformed
return {"error": str(e)}, 401
except UnauthorizedError as e:
# invalid signature or permission check failed
return {"error": str(e)}, 401
Exception Handling
All gatevault exceptions inherit from GatevaultError. Catch broadly or specifically depending on what you need.
GatevaultError
├── TokenError
│ ├── TokenExpiredError
│ ├── InvalidTokenError
│ └── TokenDecodeError
├── HashingError
└── GuardError
├── UnauthorizedError
└── InvalidCredentialsError
Importing exceptions
from gatevault import (
GatevaultError,
TokenError,
TokenExpiredError,
InvalidTokenError,
TokenDecodeError,
HashingError,
GuardError,
UnauthorizedError,
InvalidCredentialsError,
)
Catching broadly — one handler for everything
try:
tokens = oauth.login(username, password)
except GatevaultError as e:
return {"error": str(e)}, 401
Catching specifically — different response per failure
try:
payload = tm.decode_token(token)
except TokenExpiredError:
# access token expired — tell client to use refresh token
return {"error": "token expired", "code": "TOKEN_EXPIRED"}, 401
except InvalidTokenError:
# signature mismatch — possible tampering
return {"error": "invalid token", "code": "INVALID_TOKEN"}, 401
except TokenDecodeError:
# token string is malformed — bad format
return {"error": "malformed token", "code": "DECODE_ERROR"}, 400
Catching by category — group related errors
try:
payload = tm.decode_token(token)
except TokenError:
# catches all three: TokenExpiredError, InvalidTokenError, TokenDecodeError
return {"error": "token error"}, 401
try:
tokens = oauth.login(username, password)
except GuardError:
# catches InvalidCredentialsError and UnauthorizedError
return {"error": "authentication failed"}, 401
Real-world error handling pattern
from gatevault import (
GatevaultError, InvalidCredentialsError, UnauthorizedError,
TokenExpiredError, TokenDecodeError, InvalidTokenError, GuardError
)
def handle_login(username: str, password: str):
try:
tokens = oauth.login(username, password)
return {"access_token": tokens["access_token"]}, 200
except (InvalidCredentialsError, UnauthorizedError):
return {"error": "invalid credentials"}, 401
except GuardError:
return {"error": "authentication failed"}, 500
def handle_protected_request(token: str):
try:
return get_profile(token=token)
except TokenExpiredError:
return {"error": "token expired", "action": "refresh"}, 401
except (InvalidTokenError, TokenDecodeError, GuardError):
return {"error": "unauthorized"}, 401
except UnauthorizedError:
return {"error": "forbidden"}, 403
Warnings
ShortKeyWarning
Issued at TokenManager creation if the secret key is shorter than 32 bytes. HS256 requires at least 32 bytes per RFC 7518. This is a warning, not an error — your app will still run.
import warnings
from gatevault import TokenManager, ShortKeyWarning
# This triggers a ShortKeyWarning
tm = TokenManager(secret_key="tooshort", access_expiry_minutes=15, refresh_expiry_days=7)
# UserWarning: Secret key is shorter than the recommended 32 bytes for HS256...
Suppressing in tests
import warnings
from gatevault import ShortKeyWarning
warnings.filterwarnings("ignore", category=ShortKeyWarning)
tm = TokenManager(secret_key="short", access_expiry_minutes=15, refresh_expiry_days=7)
# no warning
Treating as an error in CI
warnings.filterwarnings("error", category=ShortKeyWarning)
# Now this raises instead of warning — catches misconfigurations early
tm = TokenManager(secret_key="tooshort", access_expiry_minutes=15, refresh_expiry_days=7)
# ShortKeyWarning: Secret key is shorter than the recommended 32 bytes...
Framework Integration
gatevault is framework-agnostic. Here is how it fits into FastAPI and Flask with proper token handling.
FastAPI
The full flow — register, login, protected route, refresh:
import os
from fastapi import FastAPI, Header, HTTPException, Response, Cookie
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from gatevault import TokenManager, OAuthHandler, GateVault, hash_password
from gatevault import (
InvalidCredentialsError, UnauthorizedError,
GuardError, TokenExpiredError, InvalidTokenError, TokenDecodeError
)
app = FastAPI()
tm = TokenManager(
secret_key=os.environ["AUTH_SECRET_KEY"],
access_expiry_minutes=15,
refresh_expiry_days=7
)
gate = GateVault(token_manager=tm)
oauth = OAuthHandler(token_manager=tm, get_user=get_user_from_db)
class AuthRequest(BaseModel):
username: str
password: str
# Registration
@app.post("/register")
def register(body: AuthRequest):
hashed = hash_password(body.password)
db.create_user(username=body.username, hashed_password=hashed)
return {"message": "registered"}
# Login — access token in body, refresh token in httpOnly cookie
@app.post("/login")
def login(body: AuthRequest, response: Response):
try:
tokens = oauth.login(body.username, body.password)
except (InvalidCredentialsError, UnauthorizedError):
raise HTTPException(status_code=401, detail="invalid credentials")
response.set_cookie(
key="refresh_token",
value=tokens["refresh_token"],
httponly=True,
secure=True,
samesite="strict"
)
return {"access_token": tokens["access_token"]}
# Protected functions — defined once
@gate.protected
def get_profile(payload=None):
return db.get_user(payload["user_id"])
@gate.protected
def get_orders(payload=None):
return db.get_orders(payload["user_id"])
# Routes — extract token from Authorization header, pass to protected function
@app.get("/profile")
def profile(authorization: str = Header(...)):
token = authorization.replace("Bearer ", "")
try:
return get_profile(token=token)
except (GuardError, UnauthorizedError):
raise HTTPException(status_code=401, detail="unauthorized")
@app.get("/orders")
def orders(authorization: str = Header(...)):
token = authorization.replace("Bearer ", "")
try:
return get_orders(token=token)
except (GuardError, UnauthorizedError):
raise HTTPException(status_code=401, detail="unauthorized")
# Refresh — refresh token comes from httpOnly cookie, not body
@app.post("/refresh")
def refresh(response: Response, refresh_token: str = Cookie(...)):
try:
payload = tm.decode_token(refresh_token)
except TokenExpiredError:
raise HTTPException(status_code=401, detail="session expired")
except (InvalidTokenError, TokenDecodeError):
raise HTTPException(status_code=401, detail="invalid token")
if payload["type"] != "refresh":
raise HTTPException(status_code=400, detail="wrong token type")
db.revoke_refresh_token(refresh_token)
new_access = tm.create_access_token(user_id=payload["user_id"])
new_refresh = tm.create_refresh_token(user_id=payload["user_id"])
db.store_refresh_token(new_refresh, user_id=payload["user_id"])
response.set_cookie(
key="refresh_token",
value=new_refresh,
httponly=True,
secure=True,
samesite="strict"
)
return {"access_token": new_access}
# Logout — clear the cookie
@app.post("/logout")
def logout(response: Response, refresh_token: str = Cookie(None)):
if refresh_token:
db.revoke_refresh_token(refresh_token)
response.delete_cookie("refresh_token")
return {"message": "logged out"}
Flask
import os
from flask import Flask, request, jsonify, make_response
from gatevault import TokenManager, OAuthHandler, GateVault, hash_password
from gatevault import (
InvalidCredentialsError, UnauthorizedError,
GuardError, TokenExpiredError, InvalidTokenError, TokenDecodeError
)
app = Flask(__name__)
tm = TokenManager(
secret_key=os.environ["AUTH_SECRET_KEY"],
access_expiry_minutes=15,
refresh_expiry_days=7
)
gate = GateVault(token_manager=tm)
oauth = OAuthHandler(token_manager=tm, get_user=get_user_from_db)
def get_token_from_header():
auth = request.headers.get("Authorization", "")
return auth.replace("Bearer ", "")
# Registration
@app.post("/register")
def register():
data = request.json
hashed = hash_password(data["password"])
db.create_user(username=data["username"], hashed_password=hashed)
return jsonify({"message": "registered"})
# Login — access token in body, refresh token in httpOnly cookie
@app.post("/login")
def login():
data = request.json
try:
tokens = oauth.login(data["username"], data["password"])
except (InvalidCredentialsError, UnauthorizedError):
return jsonify({"error": "invalid credentials"}), 401
response = make_response(jsonify({"access_token": tokens["access_token"]}))
response.set_cookie(
"refresh_token",
tokens["refresh_token"],
httponly=True,
secure=True,
samesite="Strict"
)
return response
# Protected functions — defined once
@gate.protected
def get_profile(payload=None):
return db.get_user(payload["user_id"])
@gate.protected
def get_orders(payload=None):
return db.get_orders(payload["user_id"])
# Routes — extract token from Authorization header
@app.get("/profile")
def profile():
token = get_token_from_header()
try:
return jsonify(get_profile(token=token))
except (GuardError, UnauthorizedError):
return jsonify({"error": "unauthorized"}), 401
@app.get("/orders")
def orders():
token = get_token_from_header()
try:
return jsonify(get_orders(token=token))
except (GuardError, UnauthorizedError):
return jsonify({"error": "unauthorized"}), 401
# Refresh — reads refresh token from httpOnly cookie
@app.post("/refresh")
def refresh():
refresh_token = request.cookies.get("refresh_token")
if not refresh_token:
return jsonify({"error": "no refresh token"}), 401
try:
payload = tm.decode_token(refresh_token)
except TokenExpiredError:
return jsonify({"error": "session expired"}), 401
except (InvalidTokenError, TokenDecodeError):
return jsonify({"error": "invalid token"}), 401
if payload["type"] != "refresh":
return jsonify({"error": "wrong token type"}), 400
db.revoke_refresh_token(refresh_token)
new_access = tm.create_access_token(user_id=payload["user_id"])
new_refresh = tm.create_refresh_token(user_id=payload["user_id"])
db.store_refresh_token(new_refresh, user_id=payload["user_id"])
response = make_response(jsonify({"access_token": new_access}))
response.set_cookie(
"refresh_token",
new_refresh,
httponly=True,
secure=True,
samesite="Strict"
)
return response
# Logout
@app.post("/logout")
def logout():
refresh_token = request.cookies.get("refresh_token")
if refresh_token:
db.revoke_refresh_token(refresh_token)
response = make_response(jsonify({"message": "logged out"}))
response.delete_cookie("refresh_token")
return response
Using gatevault in Parts
You do not have to use the whole package. Each part is independent.
Just Hashing
No tokens, no guards — just bcrypt password management:
from gatevault import hash_password, verify_password
# Registration
hashed = hash_password("user_password")
db.save_user(hashed_password=hashed)
# Login
user = db.get_user(username)
if verify_password("user_password", user.hashed_password):
# authenticated — issue tokens however you like
pass
else:
raise Exception("wrong password")
# Password change
new_hash = hash_password("new_password")
db.update_password(user_id=user.id, hashed_password=new_hash)
Just Tokens
Your own login flow, but JWT management handled by gatevault:
from gatevault import TokenManager
from gatevault import TokenExpiredError, InvalidTokenError, TokenDecodeError
tm = TokenManager(
secret_key="your-very-secure-secret-key-32-bytes",
access_expiry_minutes=30,
refresh_expiry_days=14
)
# issue tokens after your own auth check
access = tm.create_access_token(user_id=1)
refresh = tm.create_refresh_token(user_id=1)
# embed extra claims
access = tm.create_access_token(user_id=1, role="admin", plan="pro")
# decode and verify
try:
payload = tm.decode_token(access)
print(payload["user_id"]) # 1
print(payload["role"]) # "admin"
print(payload["type"]) # "access"
except TokenExpiredError:
print("expired")
except InvalidTokenError:
print("tampered")
except TokenDecodeError:
print("malformed")
Just Guards
You already have tokens, you just want decorator-based protection:
from gatevault import TokenManager, GateVault
from gatevault import GuardError, UnauthorizedError
tm = TokenManager(
secret_key="your-very-secure-secret-key-32-bytes",
access_expiry_minutes=15,
refresh_expiry_days=7
)
gate = GateVault(token_manager=tm)
@gate.protected
def get_dashboard(payload=None):
return {"user_id": payload["user_id"], "role": payload.get("role")}
@gate.protected
def admin_panel(payload=None):
if payload.get("role") != "admin":
raise UnauthorizedError("admins only")
return get_admin_data()
@gate.protected
def process_order(order_id: int, payload=None):
return db.process(order_id, user_id=payload["user_id"])
# call with token from wherever you got it
try:
result = get_dashboard(token=incoming_token)
except GuardError:
return {"error": "unauthorized"}, 401
except UnauthorizedError:
return {"error": "forbidden"}, 403
Token Refresh & Rotation
gatevault creates tokens on demand but does not manage storage or invalidation — that lives in your application.
Why you need a refresh token store
JWTs cannot be invalidated once issued. Without a store, a stolen refresh token is valid until it expires — potentially days. With a store, you can:
- Revoke tokens on logout
- Detect token reuse (a sign of theft)
- Force re-login on password change or suspicious activity
Full rotation pattern
from gatevault import TokenExpiredError, InvalidTokenError, TokenDecodeError
def rotate_tokens(refresh_token: str):
# 1. Verify the refresh token
try:
payload = tm.decode_token(refresh_token)
except TokenExpiredError:
return {"error": "session expired, please log in again"}, 401
except (InvalidTokenError, TokenDecodeError):
return {"error": "invalid token"}, 401
# 2. Check it's actually a refresh token
if payload["type"] != "refresh":
return {"error": "wrong token type"}, 400
# 3. Check it hasn't already been used (reuse detection)
if not db.is_refresh_token_valid(refresh_token):
# Token was already rotated — possible theft
# Revoke all tokens for this user and force re-login
db.revoke_all_tokens_for_user(payload["user_id"])
return {"error": "token reuse detected, please log in again"}, 401
# 4. Revoke the old refresh token
db.revoke_refresh_token(refresh_token)
# 5. Issue new pair
new_access = tm.create_access_token(user_id=payload["user_id"])
new_refresh = tm.create_refresh_token(user_id=payload["user_id"])
# 6. Store the new refresh token
db.store_refresh_token(new_refresh, user_id=payload["user_id"])
return {
"access_token": new_access,
"new_refresh_token": new_refresh
}
Minimal refresh (no reuse detection)
If you don't need reuse detection, here is the minimal version:
def rotate_tokens(refresh_token: str):
try:
payload = tm.decode_token(refresh_token)
except TokenExpiredError:
return {"error": "session expired"}, 401
except (InvalidTokenError, TokenDecodeError):
return {"error": "invalid token"}, 401
if payload["type"] != "refresh":
return {"error": "wrong token type"}, 400
new_access = tm.create_access_token(user_id=payload["user_id"])
new_refresh = tm.create_refresh_token(user_id=payload["user_id"])
return {"access_token": new_access, "refresh_token": new_refresh}
Security Guide
Secret key
- Use at least 32 bytes — gatevault warns you if you don't
- Generate with:
python -c "import secrets; print(secrets.token_hex(32))" - Store in an environment variable — never hardcode
- Rotating invalidates all existing tokens immediately — only do it when necessary (breach, employee offboarding)
Token storage on the client
| Token | Where to store | Why |
|---|---|---|
| Access token | Memory (JS variable) | Short-lived, not persisted, wiped on tab close |
| Refresh token | httpOnly cookie | Can't be read by JavaScript — XSS safe |
Never store tokens in localStorage — it is accessible to any JavaScript on the page, including injected scripts.
What not to put in the payload
The JWT payload is base64 encoded, not encrypted. Anyone with the token string can decode and read it. Keep it to identifiers and non-sensitive metadata:
# Fine
tm.create_access_token(user_id=42, role="admin", org_id=7)
# Never do this
tm.create_access_token(user_id=42, email="john@example.com", password_hash="$2b$...")
Refresh token revocation
Tokens cannot be invalidated once issued. For true revocation (logout, password change, suspicious activity), maintain a table of valid refresh tokens in your database:
CREATE TABLE refresh_tokens (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
);
On every refresh request, check the token exists in this table before issuing a new pair. On logout or password change, delete the row.
Login enumeration
Return the same error for "user not found" and "wrong password". Distinguishing them tells an attacker which usernames exist:
# Good
except (InvalidCredentialsError, UnauthorizedError):
return {"error": "invalid credentials"}, 401
# Bad — tells attacker the username is valid
except InvalidCredentialsError:
return {"error": "user not found"}, 404
except UnauthorizedError:
return {"error": "wrong password"}, 401
API Reference
TokenManager(secret_key, access_expiry_minutes, refresh_expiry_days)
| Parameter | Type | Description |
|---|---|---|
secret_key |
str |
Secret for signing tokens. Minimum 32 bytes recommended. |
access_expiry_minutes |
int |
Access token lifetime in minutes. |
refresh_expiry_days |
int |
Refresh token lifetime in days. |
| Method | Returns | Description |
|---|---|---|
create_access_token(user_id, **kwargs) |
str |
Creates a signed access token. Extra kwargs embedded in payload. |
create_refresh_token(user_id, **kwargs) |
str |
Creates a signed refresh token. Extra kwargs embedded in payload. |
decode_token(token) |
dict |
Verifies signature and expiry. Returns payload dict. Raises on failure. |
OAuthHandler(token_manager, get_user)
| Parameter | Type | Description |
|---|---|---|
token_manager |
TokenManager |
Configured TokenManager instance. |
get_user |
Callable[[str], Any | None] |
Lookup function. Must return object with id and hashed_password, or None. |
| Method | Returns | Description |
|---|---|---|
login(username, password) |
dict |
Authenticates user. Returns {"access_token", "refresh_token", "token_type"}. |
GateVault(token_manager)
| Parameter | Type | Description |
|---|---|---|
token_manager |
TokenManager |
Configured TokenManager instance. |
| Method | Returns | Description |
|---|---|---|
protected(f) |
Callable |
Decorator. Verifies token, injects decoded payload as payload kwarg. |
hash_password(plain) -> str
| Parameter | Type | Description |
|---|---|---|
plain |
str |
Plain text password. Encoding is handled internally. |
Returns bcrypt hash string. Raises HashingError on unexpected failure.
verify_password(plain, hashed) -> bool
| Parameter | Type | Description |
|---|---|---|
plain |
str |
Plain text password to check. |
hashed |
str |
Stored bcrypt hash. |
Returns True if match, False otherwise. Never raises on wrong password.
Design Decisions
Framework-agnostic
Tying gatevault to FastAPI or Flask would limit who can use it. Auth logic — hashing, signing, verifying — has nothing to do with HTTP. Pure Python means it works anywhere.
Class-based TokenManager
The secret key and expiry settings are configuration — they belong on an instance, not passed into every function call. Configure once at startup, use everywhere without threading arguments through every call.
Shared TokenManager across OAuthHandler and GateVault
OAuthHandler creates tokens. GateVault verifies them. They don't communicate directly — the shared TokenManager instance is the trust anchor. Same secret key in, same secret key out.
Payload injected as a keyword argument
payload=payload is explicit. The decorated function always knows where its auth data comes from. Positional injection would silently break functions whose arguments don't match the expected order.
Wrapping third-party exceptions
PyJWT and bcrypt exceptions never surface through the gatevault API. Consumers only need to know gatevault exceptions. If an underlying library changes exception names in a future version, only gatevault updates.
verify_password returns bool, not raises
A wrong password is an expected outcome, not an exceptional one. The caller decides whether to raise, log, increment a failed attempt counter, or something else entirely.
OAuthHandler.login returns both tokens
The server decides how to deliver each token to the client — access token in the body, refresh token in an httpOnly cookie. Returning both from login gives the framework integration layer that flexibility.
Known Limitations
- Refresh token invalidation is not built in — you need a database or cache to track and revoke issued refresh tokens
- Only HS256 (symmetric signing) is supported — RS256 (asymmetric keypair) is not yet available
GateVault.protectedexpectstokenas a keyword argument — you may need a thin adapter in frameworks with unusual request injection patterns- No built-in rate limiting on login attempts — implement at the application or infrastructure level
- No async support — all methods are synchronous. Async wrappers are on the roadmap
Future Improvements
- RS256 support for asymmetric key signing
- Built-in token blocklist interface for revocation
- Async-compatible versions of all methods (
async def) - Role-based access control helpers on
GateVault - FastAPI and Flask integration packages as optional extras
Contributing
Contributions are welcome. Open an issue first to discuss what you want to change, especially for anything touching the security-sensitive parts.
git clone https://github.com/RichardOyelowo/gatevault
cd gatevault
pip install -e .
pip install pytest
pytest tests/ -v
License
Apache 2.0 — see LICENSE for details.
Built by Richard for the love of development.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file richard_gatevault-1.0.2.tar.gz.
File metadata
- Download URL: richard_gatevault-1.0.2.tar.gz
- Upload date:
- Size: 110.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
58052c9d93bdb30bf427985787dee470b5ce32f705107aaec5a9566df72d635e
|
|
| MD5 |
ac3165d619b4ee0c76fc61028f80349c
|
|
| BLAKE2b-256 |
b8ccf3e86c20c55050d1bf87dbff89d865f1044154424d2d3b17a813b64fb4a1
|
File details
Details for the file richard_gatevault-1.0.2-py3-none-any.whl.
File metadata
- Download URL: richard_gatevault-1.0.2-py3-none-any.whl
- Upload date:
- Size: 18.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
da69921cc5cffcf4d65b4a4a2c536df6f19cccc5a4ce3c849cf86df85f64145f
|
|
| MD5 |
b94232081bc89acf926d99eef2e81e1a
|
|
| BLAKE2b-256 |
614e5614d0161562834d72684d96cd3c21324194be4b0fac505fb579973656a5
|