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 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

Before going into each feature, here is what a complete auth setup looks like from registration to protected route access. If you only need one specific feature, jump to the relevant section.

from gatevault import (
    TokenManager, OAuthHandler, GateVault,
    hash_password, verify_password,
    InvalidCredentialsError, UnauthorizedError, TokenExpiredError
)

# --- Setup (once at app startup) ---

tm = TokenManager(
    secret_key="your-very-secure-secret-key-minimum-32-bytes",
    access_expiry_minutes=15,
    refresh_expiry_days=7
)

gate = GateVault(token_manager=tm)

def get_user(username):
    return db.get_user_by_email(username)  # your own lookup

oauth = OAuthHandler(token_manager=tm, get_user=get_user)


# --- Registration ---

def register(username, plain_password):
    hashed = hash_password(plain_password)
    db.create_user(username=username, hashed_password=hashed)


# --- Login ---

def login(username, password):
    try:
        return oauth.login(username, password)
        # {"access_token": "...", "refresh_token": "...", "token_type": "bearer"}
    except InvalidCredentialsError:
        return 404, "user not found"
    except UnauthorizedError:
        return 401, "wrong password"


# --- Protected route ---

@gate.protected
def get_profile(payload=None):
    user_id = payload["user_id"]
    return db.get_user(user_id)


# --- Calling a protected route ---

tokens = login("john@example.com", "password123")
profile = get_profile(token=tokens["access_token"])

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. This is intentional. 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.

# At registration
def create_account(username, plain_password):
    hashed = hash_password(plain_password)
    db.insert(username=username, password=hashed)

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.

# At login
def authenticate(username, plain_password):
    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. verify_password extracts the salt automatically from the stored hash during verification.

h1 = hash_password("same_password")
h2 = hash_password("same_password")
print(h1 == h2)  # False — different salts, both valid
print(verify_password("same_password", h1))  # True
print(verify_password("same_password", h2))  # True

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

from gatevault import TokenManager

tm = TokenManager(
    secret_key="your-very-secure-secret-key-minimum-32-bytes",
    access_expiry_minutes=15,
    refresh_expiry_days=7
)

The secret_key is what signs your tokens. Anyone with this key can create valid tokens, so keep it secret and never commit it to source control. Use an environment variable:

import os
from gatevault import TokenManager

tm = TokenManager(
    secret_key=os.environ["AUTH_SECRET_KEY"],
    access_expiry_minutes=int(os.environ.get("ACCESS_EXPIRY_MINUTES", 15)),
    refresh_expiry_days=int(os.environ.get("REFRESH_EXPIRY_DAYS", 7))
)

Creating tokens

access_token = tm.create_access_token(user_id=42)
refresh_token = tm.create_refresh_token(user_id=42)

Both methods return a JWT string. Access tokens are short-lived — meant to be sent with every authenticated request. Refresh tokens are long-lived — used only to obtain a new access token when the current one expires.

Extra claims

You can embed additional data in the token payload using keyword arguments:

token = tm.create_access_token(user_id=42, role="admin", org_id=7)

payload = tm.decode_token(token)
print(payload)
# {"user_id": 42, "exp": 1234567890, "type": "access", "role": "admin", "org_id": 7}

This is useful for embedding role or permission data so your guards can make access decisions without hitting the database on every request.

Decoding tokens

payload = tm.decode_token(token)
user_id = payload["user_id"]
token_type = payload["type"]  # "access" or "refresh"

decode_token verifies the signature and checks expiry automatically. It raises specific exceptions on failure rather than returning None or a status code — so you know exactly what went wrong.

from gatevault import TokenExpiredError, InvalidTokenError, TokenDecodeError

try:
    payload = tm.decode_token(token)
except TokenExpiredError:
    # send the client to the refresh endpoint
    pass
except InvalidTokenError:
    # signature mismatch — possible tampering
    pass
except TokenDecodeError:
    # malformed token string
    pass

Access vs refresh — tell them apart

Every token has a type claim embedded in the payload. Check it if 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")

This prevents someone from using a refresh token where an access token is expected.


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 user does not 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, passed into the token payload
  • hashed_password — the bcrypt hash stored at registration

If your model uses different field names, add a property:

class User:
    # your model fields...

    @property
    def hashed_password(self):
        return self.password_hash  # whatever your field is called

Logging in

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 it returns None
  2. Calls verify_password — raises UnauthorizedError if the password does not match
  3. Creates and returns both tokens

Handling login errors

from gatevault import InvalidCredentialsError, UnauthorizedError, GuardError

try:
    tokens = oauth.login(username, password)
except InvalidCredentialsError:
    return {"error": "Invalid credentials"}, 401
except UnauthorizedError:
    return {"error": "Invalid credentials"}, 401
except GuardError:
    return {"error": "Authentication failed"}, 500

Note: returning the same error message for both InvalidCredentialsError and UnauthorizedError is intentional. Telling an attacker which one failed helps them enumerate valid usernames.


Protecting Routes

GateVault is a decorator factory that wraps any function with token verification. The wrapped function never executes if the token is missing, expired, or invalid.

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)

# call the function by passing the token
result = get_profile(token="eyJhbGci...")

The decoded payload is injected as the payload keyword argument. Declaring payload=None as a default is a good habit so the function signature is clear.

Accessing claims from the payload

@gate.protected
def admin_only(payload=None):
    if payload.get("role") != "admin":
        raise UnauthorizedError("admin access required")
    return get_admin_data()

Any extra claims embedded at token creation time are available in the payload here.

Passing other arguments

The guard passes through any additional arguments to your function untouched:

@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

get_post(post_id=7, token="eyJhbGci...")

Handling guard errors

from gatevault import GuardError, UnauthorizedError

try:
    result = get_profile(token=incoming_token)
except GuardError as e:
    return {"error": str(e)}, 401
except UnauthorizedError as e:
    return {"error": str(e)}, 401

Exception Handling

All gatevault exceptions inherit from GatevaultError. You can catch at any level of the hierarchy depending on how granular your error handling needs to be.

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

try:
    tokens = oauth.login(username, password)
except GatevaultError as e:
    return {"error": str(e)}, 401

Catching specifically

try:
    payload = tm.decode_token(token)
except TokenExpiredError:
    return {"error": "token expired"}, 401
except InvalidTokenError:
    return {"error": "invalid token"}, 401
except TokenDecodeError:
    return {"error": "malformed token"}, 400

Catching by category

try:
    payload = tm.decode_token(token)
except TokenError:
    # catches TokenExpiredError, InvalidTokenError, and TokenDecodeError
    return {"error": "token error"}, 401

Warnings

ShortKeyWarning

Issued at TokenManager creation if the secret key is shorter than 32 bytes. HS256 requires at least 32 bytes for adequate security per RFC 7518. This is a warning, not an error — your app will still run, but you should use a longer key in production.

import warnings
from gatevault import ShortKeyWarning

# suppress in tests
warnings.filterwarnings("ignore", category=ShortKeyWarning)

# treat as a hard error in CI to catch misconfigurations early
warnings.filterwarnings("error", category=ShortKeyWarning)

To generate a secure key:

python -c "import secrets; print(secrets.token_hex(32))"

Framework Integration

gatevault is framework-agnostic but slots naturally into FastAPI and Flask.

FastAPI

import os
from fastapi import FastAPI, Header, HTTPException
from gatevault import TokenManager, OAuthHandler, GateVault, hash_password
from gatevault import InvalidCredentialsError, UnauthorizedError, GuardError, TokenExpiredError

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)


@app.post("/register")
def register(username: str, password: str):
    hashed = hash_password(password)
    db.create_user(username=username, hashed_password=hashed)
    return {"message": "registered"}


@app.post("/login")
def login(username: str, password: str):
    try:
        return oauth.login(username, password)
    except (InvalidCredentialsError, UnauthorizedError):
        raise HTTPException(status_code=401, detail="Invalid credentials")


@app.get("/profile")
def profile(authorization: str = Header(...)):
    token = authorization.replace("Bearer ", "")

    @gate.protected
    def _inner(payload=None):
        return {"user_id": payload["user_id"]}

    try:
        return _inner(token=token)
    except (GuardError, UnauthorizedError):
        raise HTTPException(status_code=401, detail="Unauthorized")


@app.post("/refresh")
def refresh(refresh_token: str):
    try:
        payload = tm.decode_token(refresh_token)
        if payload["type"] != "refresh":
            raise HTTPException(status_code=400, detail="expected refresh token")
        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, "token_type": "bearer"}
    except TokenExpiredError:
        raise HTTPException(status_code=401, detail="refresh token expired")

Flask

import os
from flask import Flask, request, jsonify
from gatevault import TokenManager, OAuthHandler, GateVault, hash_password
from gatevault import InvalidCredentialsError, UnauthorizedError, GuardError

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_request():
    auth = request.headers.get("Authorization", "")
    return auth.replace("Bearer ", "")


@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"})


@app.post("/login")
def login():
    data = request.json
    try:
        tokens = oauth.login(data["username"], data["password"])
        return jsonify(tokens)
    except (InvalidCredentialsError, UnauthorizedError):
        return jsonify({"error": "invalid credentials"}), 401


@app.get("/profile")
def profile():
    token = get_token_from_request()

    @gate.protected
    def _inner(payload=None):
        return jsonify({"user_id": payload["user_id"]})

    try:
        return _inner(token=token)
    except (GuardError, UnauthorizedError):
        return jsonify({"error": "unauthorized"}), 401

Using gatevault in Parts

You do not have to use the whole package. Each part is independent and works on its own.

Just Hashing

No tokens, no guards — just password hashing and verification:

from gatevault import hash_password, verify_password

# at registration
hashed = hash_password("user_password")
db.save(hashed)

# at login
if verify_password("user_password", db.get_hash(user_id)):
    # proceed with login
    pass

No other setup needed. hash_password and verify_password are standalone functions with no dependencies on the rest of the package.


Just Tokens

If you have your own login and auth flow and only want JWT management:

from gatevault import TokenManager
from gatevault import TokenExpiredError, InvalidTokenError, TokenDecodeError

tm = TokenManager(
    secret_key="your-secret",
    access_expiry_minutes=30,
    refresh_expiry_days=14
)

# issue tokens however you like
access = tm.create_access_token(user_id=1)
refresh = tm.create_refresh_token(user_id=1)

# with extra claims
access = tm.create_access_token(user_id=1, role="admin")

# decode later
try:
    payload = tm.decode_token(access)
    print(payload["user_id"], payload["role"])
except TokenExpiredError:
    pass
except InvalidTokenError:
    pass

Just Guards

If you already have tokens from your own system and just want decorator-based protection:

from gatevault import TokenManager, GateVault
from gatevault import GuardError, UnauthorizedError

tm = TokenManager(
    secret_key="your-secret",
    access_expiry_minutes=15,
    refresh_expiry_days=7
)
gate = GateVault(token_manager=tm)

@gate.protected
def sensitive_action(data: dict, payload=None):
    user_id = payload["user_id"]
    return process(data, user_id)

try:
    result = sensitive_action(data={"key": "value"}, token=incoming_token)
except (GuardError, UnauthorizedError):
    return 401

Token Refresh & Rotation

gatevault creates new tokens on demand but does not manage token storage or invalidation — that lives in your application. Here is the recommended pattern:

The refresh flow

from gatevault import TokenExpiredError, InvalidTokenError, TokenDecodeError

def refresh_tokens(refresh_token: str):
    try:
        payload = tm.decode_token(refresh_token)
    except TokenExpiredError:
        # refresh token expired — force re-login
        return {"error": "session expired"}, 401
    except (InvalidTokenError, TokenDecodeError):
        return {"error": "invalid token"}, 401

    if payload["type"] != "refresh":
        return {"error": "wrong token type"}, 400

    # invalidate the old refresh token in your database
    db.revoke_refresh_token(refresh_token)

    # issue a new pair
    new_access = tm.create_access_token(user_id=payload["user_id"])
    new_refresh = tm.create_refresh_token(user_id=payload["user_id"])

    # store the new refresh token
    db.store_refresh_token(new_refresh, user_id=payload["user_id"])

    return {
        "access_token": new_access,
        "refresh_token": new_refresh,
        "token_type": "bearer"
    }

Why rotation matters

Without rotation, a stolen refresh token is valid until it expires — potentially days. With rotation, every use of the refresh token produces a new one and the old one is revoked. If a stolen token is used, the legitimate user's next refresh attempt will fail because their token was already rotated, alerting them that something is wrong.


Security Guide

Secret key

  • Use at least 32 bytes — gatevault will warn you if you don't
  • Generate with python -c "import secrets; print(secrets.token_hex(32))"
  • Never hardcode it — use environment variables
  • Rotate it only when necessary — rotation invalidates all existing tokens immediately

Token storage on the client

  • Access token — store in memory (a JS variable). Short-lived so the exposure window is small. Do not store in localStorage.
  • Refresh token — store in an httpOnly cookie. httpOnly prevents JavaScript from reading it, which reduces XSS exposure. Send it only to your refresh endpoint, never on regular requests.

What not to put in a token payload

The payload is base64 encoded, not encrypted. Anyone with the token string can decode and read it. Never put passwords, credit card numbers, or any sensitive data in the payload. user_id, role, and org_id are fine.

Refresh token revocation

Tokens cannot be invalidated once issued — that is a fundamental property of stateless JWTs. If you need true revocation (e.g. logout, password change, suspicious activity), maintain a blocklist in your database or cache and check against it in your refresh endpoint or guard logic.

Login enumeration

When a login fails, return the same error message whether the username does not exist or the password is wrong. Distinguishing between the two tells an attacker which usernames are valid accounts.


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 and decodes a token. Raises on failure.

OAuthHandler(token_manager, get_user)

Parameter Type Description
token_manager TokenManager Configured TokenManager instance.
get_user Callable[[str], Any | None] User lookup function. Must return object with id and hashed_password, or None.
Method Returns Description
login(username, password) dict Authenticates user and returns access/refresh token pair.

GateVault(token_manager)

Parameter Type Description
token_manager TokenManager Configured TokenManager instance.
Method Returns Description
protected(f) Callable Decorator. Verifies token before function runs, injects payload as kwarg.

hash_password(plain) -> str

Parameter Type Description
plain str Plain text password. Pass the raw string — encoding is handled internally.

Returns the bcrypt hash as a string. Raises HashingError if bcrypt fails unexpectedly.


verify_password(plain, hashed) -> bool

Parameter Type Description
plain str Plain text password to check.
hashed str Stored bcrypt hash to check against.

Returns True if the password matches, False otherwise. Never raises on a wrong password.


Design Decisions

Framework-agnostic

Tying gatevault to FastAPI or Flask would limit who can use it. The core auth logic — hashing, signing, verifying — has nothing to do with HTTP. Keeping it pure Python means it works anywhere and framework-specific integrations can be layered on top.

Class-based TokenManager instead of functions

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.

Payload injected as a keyword argument

payload=payload is explicit. The decorated function always knows where its auth context comes from and can choose to accept it or ignore it. Positional injection would silently break functions whose arguments do not match the expected order.

Wrapping third-party exceptions

PyJWT and bcrypt exceptions never leak through the gatevault API. Consumers only need to know gatevault exceptions. If an underlying library changes its exception names in a future version, only gatevault updates — not every app built on it.

verify_password returns bool, not raises

A wrong password is an expected outcome, not an exceptional one. The caller decides what to do — raise, log, increment a failed attempt counter, or something else.


Known Limitations

  • Refresh token invalidation is not handled by gatevault — 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 the token as a keyword argument — you may need a thin adapter in frameworks that inject request objects differently
  • No built-in rate limiting on login attempts — implement this at the application or infrastructure level

Future Improvements

  • RS256 support for asymmetric key signing
  • Built-in token blocklist interface for revocation
  • Async-compatible versions of all methods
  • Role-based access control helpers on GateVault
  • FastAPI and Flask integration packages

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 ".[dev]"
pytest

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.1.tar.gz (15.5 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.1-py3-none-any.whl (14.9 kB view details)

Uploaded Python 3

File details

Details for the file richard_gatevault-1.0.1.tar.gz.

File metadata

  • Download URL: richard_gatevault-1.0.1.tar.gz
  • Upload date:
  • Size: 15.5 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.1.tar.gz
Algorithm Hash digest
SHA256 4bda12b16c9169b4cf456eb7d9ca3142cd872c73b9505b1ea946a639c00d5e11
MD5 3d9cf35136fe37bc06946f847620d712
BLAKE2b-256 85b2c818882ece30d289f2a40b1366a98a19039c7e898270b75e87b2658b6deb

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for richard_gatevault-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 96b656fca54b60fe2d599b5c481d086c58d067f813e9a2bfba37fd9d140bf87f
MD5 399723c847f6ccc24913e660b94ad427
BLAKE2b-256 c66993b75b631522bb219db5367618cc8819bca3f2348b05c1b54b2b481e4234

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