Skip to main content

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

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 payload
  • hashed_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:

  1. Calls get_user(username) — raises InvalidCredentialsError if None is returned
  2. Calls verify_password(password, user.hashed_password) — raises UnauthorizedError if it returns False
  3. Calls create_access_token and create_refresh_token — raises GuardError if 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 InvalidCredentialsError and UnauthorizedError. 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.protected expects token as 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

richard_gatevault-1.0.2.tar.gz (110.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

richard_gatevault-1.0.2-py3-none-any.whl (18.4 kB view details)

Uploaded Python 3

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

Hashes for richard_gatevault-1.0.2.tar.gz
Algorithm Hash digest
SHA256 58052c9d93bdb30bf427985787dee470b5ce32f705107aaec5a9566df72d635e
MD5 ac3165d619b4ee0c76fc61028f80349c
BLAKE2b-256 b8ccf3e86c20c55050d1bf87dbff89d865f1044154424d2d3b17a813b64fb4a1

See more details on using hashes here.

File details

Details for the file richard_gatevault-1.0.2-py3-none-any.whl.

File metadata

File hashes

Hashes for richard_gatevault-1.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 da69921cc5cffcf4d65b4a4a2c536df6f19cccc5a4ce3c849cf86df85f64145f
MD5 b94232081bc89acf926d99eef2e81e1a
BLAKE2b-256 614e5614d0161562834d72684d96cd3c21324194be4b0fac505fb579973656a5

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page