Security Policy & Authentication
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Pyramid 2.1 (released 2026-03-11), checked on 2026-10-07. The code was executed on
Python 3.13 with Pyramid 2.1, WebTest 3.0.7, argon2-cffi 25.1.0 and bcrypt 5.0.0; every example that is not marked as
a file listing runs as shown, driven through webtest.TestApp. Authentication answers the question "who is making this
request?". Pyramid answers it with one object, the security policy, which also answers the question "may this user
do this?" (authorization, covered on the next page of this section) and knows how to remember a user between requests.
The running example is the Bookshelf Shelves scenario: users own shelves, anybody may read a public shelf and only
the owner may edit it. This page builds the sign-in half of that story.
Every web framework has some answer to this problem, and the differences are instructive.
Flask leaves it to the Flask-Login extension and its
user_loader callback, Tornado overrides
get_current_user on the handler, Django ships a complete user model, sessions
and password hashers, and FastAPI composes security dependencies.
Pyramid ships no user model, no login form and no built-in policy. It provides the socket (an interface called
ISecurityPolicy), a set of helpers that do the cryptographically fiddly parts, and the machinery that calls your
policy before a protected view runs. What a user is, where users are stored and how they prove who they are stays
entirely in your code.
The Big Picture
A security policy is a plain class with five methods. Pyramid calls them at well-defined points, and several request properties are thin wrappers around them.
The five methods of pyramid.interfaces.ISecurityPolicy:
| Method | Called by | Must return |
|---|---|---|
|
|
Any object that describes the current user (a user id string, a dict, an ORM object), or |
|
|
A string user id for a trusted and verified user, or |
|
View lookup when a view has a |
An instance of |
|
|
A list of header tuples (usually |
|
|
A list of header tuples that make the client forget the user (usually an expired cookie). |
Register the policy with config.set_security_policy(policy), or pass Configurator(security_policy=policy) to the
constructor. There is one policy per application; two calls to set_security_policy in the same configuration are a
ConfigurationConflictError like any other conflict, unless one of them comes from an including module and the other
from the includer, which wins (see
Configuration & the Configurator).
Source: Pyramid: Security (Writing a Security Policy) and pyramid.interfaces
Without a Policy Nothing Is Protected
Pyramid does not enable a security policy by default, and the consequence deserves emphasis because it is silent. A
view registered with permission="edit" while no policy is configured is not refused: the permission is ignored and
the view runs for everybody. request.has_permission() likewise returns Allowed('No security policy in use.').
Run on Pyramid 2.1, a bare Configurator() with one such view answers 200 OK to an anonymous request. If a
protected page is ever reachable without signing in, the first thing to check is that set_security_policy really was
called in the code path that built the application, for example inside the main() of the cookiecutter project or in
an includeme that was never included.
Request Properties and Predicates
Once a policy is registered, these members of the request delegate to it.
| API | What it does |
|---|---|
|
Calls |
|
Calls |
|
|
|
Calls |
|
A view predicate (new in 2.0) that matches only signed-in (or only anonymous) requests, so the same route can serve a dashboard to members and a landing page to visitors. |
|
Return the headers of the policy’s |
In the 1.x series the question "who is this?" had three answers: unauthenticated_userid (the claimed id, unchecked),
authenticated_userid (the id after the groupfinder callback approved it) and effective_principals. In 2.x there
is one trusted id and one opaque identity. request.unauthenticated_userid and request.effective_principals still
exist as deprecated compatibility shims (see Migrating from Authentication and Authorization Policies).
Source: pyramid.request (identity, authenticated_userid, is_authenticated, has_permission) and pyramid.security
The Authentication Helpers
The helpers are not policies: Pyramid 2.x "does not provide any built-in security policies", as the official upgrade
notes put it. They are building blocks that your policy delegates to for the storage half of the job, the part that
implements remember, forget and "which user id did this request present?".
| Helper | Where the user id lives | Choose it when |
|---|---|---|
|
A signed "auth ticket" cookie (the |
You want stateless, horizontally scalable sign-in without a session store, or you share the cookie across sibling subdomains. |
|
The Pyramid session ( |
You already use sessions, want to store more than a user id next to it, or want to end every sign-in by clearing the session store. |
|
The |
HTTP Basic for APIs and scripts (always over HTTPS). |
|
Set by the web server or a WSGI middleware that authenticated the user (Kerberos, client certificates, an authenticating reverse proxy). |
The authentication happens outside Pyramid and you only trust the result. |
There is no helper for bearer tokens; the section on APIs below shows how little code they need.
Source: Pyramid: Security (Writing a Security Policy Using Helpers) and pyramid.authentication
A Complete Cookie-Based Policy
The first example gives the Bookshelf Shelves application real sign-in. It has four parts: a tiny user store (a dictionary standing in for a database, which the SQLAlchemy page replaces with a real database), password hashing, the policy, and the login and logout views. They are shown in that order, then wired into one application. The password hashing is introduced here without explanation and examined in Password Hashing.
import secrets
from dataclasses import dataclass
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
hasher = PasswordHasher()
# A real hash of a random password, used to keep the cost of a failed login
# the same whether or not the user exists.
DUMMY_HASH = hasher.hash(secrets.token_urlsafe(16))
@dataclass
class User:
id: str
name: str
password_hash: str
is_admin: bool = False
class UserStore:
"""Stands in for a database table; counts how often it is asked."""
def __init__(self):
self.by_id = {}
self.lookups = 0
def add(self, user_id, name, password, is_admin=False):
self.by_id[user_id] = User(user_id, name, hasher.hash(password), is_admin)
def get(self, user_id):
self.lookups += 1
return self.by_id.get(user_id)
def by_name(self, name):
return next((u for u in self.by_id.values() if u.name == name), None)
def check_password(user, password):
"""Verify against the real hash, or the dummy one for unknown users."""
stored = user.password_hash if user else DUMMY_HASH
try:
hasher.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
return user is not None
Source: argon2-cffi: API Reference
The policy keeps the cookie logic in AuthTktCookieHelper and adds the two things the helper cannot know: which
user the cookie refers to, and whether that user still exists.
from pyramid.authentication import AuthTktCookieHelper
from pyramid.request import RequestLocalCache
from pyramid.security import Allowed, Denied
class ShelvesSecurityPolicy:
def __init__(self, secret, store, secure=True):
self.helper = AuthTktCookieHelper(
secret,
cookie_name="shelves_auth",
secure=secure, # send only over HTTPS
http_only=True, # hidden from JavaScript
samesite="Lax", # the default, stated for clarity
timeout=8 * 3600, # tickets older than 8 h are refused
reissue_time=900, # sliding window: re-issue after 15 min
)
self.store = store
self.identity_cache = RequestLocalCache(self.load_identity)
def load_identity(self, request):
ticket = self.helper.identify(request)
if ticket is None:
return None
# Re-check the database: a deleted user must stop working at once.
return self.store.get(ticket["userid"])
def identity(self, request):
return self.identity_cache.get_or_create(request)
def authenticated_userid(self, request):
user = self.identity(request)
return None if user is None else user.id
def permits(self, request, context, permission):
user = self.identity(request)
if user is None:
return Denied("anonymous users have no permissions")
# The next page replaces this rule with ACLs.
return Allowed(f"{user.name} is signed in")
def remember(self, request, userid, **kw):
return self.helper.remember(request, userid, **kw)
def forget(self, request, **kw):
return self.helper.forget(request)
Source: pyramid.authentication (AuthTktCookieHelper) and pyramid.request (RequestLocalCache)
Three details of this class are worth a second look.
-
AuthTktCookieHelper.identify(request)returnsNonewhen there is no cookie, when the signature does not verify and when the ticket is older thantimeout; otherwise it returns a dict withuserid,tokens,userdataandtimestamp. It proves only that the cookie was issued by someone holding the secret. It does not prove the user still exists, which is whyload_identitygoes back to the store. -
permitshere grants everything to any signed-in user, which is too coarse for Bookshelf Shelves and exists only to make the example complete. The authorization page of this section replaces it with ACLs andACLHelper. -
forgetaccepts**kwbecause the interface says so, but the helper’sforget(request)takes none; the policy decides what each keyword means.
The views and the application wiring follow. The login view is a classic post, redirect, get flow: on success it
answers 303 See Other with the Set-Cookie headers produced by remember.
from urllib.parse import quote, urlsplit
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPForbidden, HTTPSeeOther
from pyramid.security import forget, remember
from pyramid.settings import asbool
def safe_next(request, default="/"):
"""Accept only local paths for the post-login redirect."""
target = request.params.get("next", default)
parts = urlsplit(target)
if parts.scheme or parts.netloc or not target.startswith("/") \
or target.startswith("//") or "\\" in target:
return default
return target
def login(request):
username = request.POST.get("username", "")
password = request.POST.get("password", "")
user = request.user_store.by_name(username)
if not check_password(user, password):
request.response.status = 401
return {"error": "invalid credentials"}
if hasher.check_needs_rehash(user.password_hash):
user.password_hash = hasher.hash(password)
return HTTPSeeOther(location=safe_next(request), headers=remember(request, user.id))
def logout(request):
return HTTPSeeOther(location="/", headers=forget(request))
def me(request):
user = request.identity
return {"id": user.id, "name": user.name}
def shelf_edit(request):
return {"shelf": request.matchdict["owner"], "editing": True}
def forbidden(request):
if request.is_authenticated:
request.response.status = 403
return {"error": "forbidden"}
# Anonymous: send the visitor to the login page and come back afterwards.
return HTTPSeeOther(location="/login?next=" + quote(request.path_qs))
def make_app(settings, store):
with Configurator(settings=settings) as config:
secure = asbool(settings.get("auth.secure", "true"))
config.set_security_policy(
ShelvesSecurityPolicy(settings["auth.secret"], store, secure=secure)
)
config.add_request_method(lambda request: store, "user_store", reify=True)
config.add_route("login", "/login")
config.add_route("logout", "/logout")
config.add_route("me", "/me")
config.add_route("shelf_edit", "/shelves/{owner}/edit")
config.add_view(login, route_name="login", request_method="POST", renderer="json")
config.add_view(logout, route_name="logout", request_method="POST")
config.add_view(me, route_name="me", renderer="json", is_authenticated=True)
config.add_view(shelf_edit, route_name="shelf_edit", permission="edit", renderer="json")
config.add_forbidden_view(forbidden, renderer="json")
return config.make_wsgi_app()
Source: Pyramid: Security and Pyramid: Using Hooks (Changing the Forbidden View)
The settings come from the environment in a real deployment (see the secret-sharing section). In a test they are generated on the spot.
from webtest import TestApp
store = UserStore()
ada_password = secrets.token_urlsafe(12)
store.add("ada", "ada", ada_password)
settings = {"auth.secret": secrets.token_hex(32), "auth.secure": "false"}
app = TestApp(make_app(settings, store))
# Anonymous: the Forbidden View sends the visitor to the login page.
r = app.get("/shelves/ada/edit", status=303)
print(r.status, r.location)
# Wrong password: 401, no cookie.
r = app.post("/login", {"username": "ada", "password": "wrong"}, status=401)
print(r.status, r.json, "Set-Cookie" in r.headers)
# Sign in.
r = app.post("/login", {"username": "ada", "password": ada_password,
"next": "/shelves/ada/edit"}, status=303)
print(r.status, r.location)
cookie = r.headers["Set-Cookie"].split(";")[0].split("=")[0]
print("cookie:", cookie, "| attributes:", r.headers["Set-Cookie"].split("; ")[1:])
# The TestApp keeps the cookie jar, so we are now signed in.
print(app.get("/me").json)
print(app.get("/shelves/ada/edit").json)
# Sign out.
app.post("/logout", status=303)
app.get("/me", status=404) # is_authenticated=True no longer matches
print("lookups so far:", store.lookups)
# The redirect target is checked: only local paths survive.
from pyramid.testing import DummyRequest
for target in ("/shelves/ada", "https://evil.example/", "//evil.example/",
"/\\evil.example", "javascript:alert(1)"):
print(repr(target), "->", safe_next(DummyRequest(params={"next": target})))
The run printed 303 See Other with the /login?next=/shelves/ada/edit location for the anonymous visitor,
401 Unauthorized without any cookie for the wrong password, and, after a successful login, a redirect back to the
shelf plus a cookie named shelves_auth with the attributes Domain=localhost, Path=/, HttpOnly and
SameSite=Lax. /me then returned the user, and the protected edit view answered. After logout the /me route
answers 404 because the only view registered for it requires is_authenticated=True: predicate mismatches fall
through to Not Found, not to the Forbidden View. The safe_next check kept /shelves/ada and replaced the four
hostile targets (an absolute URL, a protocol-relative URL, a backslash trick and a javascript: URL) with /.
Views & View Configuration shows how to observe the lookup
behind that 404 with pviews.
Source: Pyramid: Security and Pyramid: Testing (WebTest functional tests)
Pitfalls of the Login Flow
-
Open redirects. Redirecting to a
nextparameter without checking it lets an attacker craft a login link that bounces the victim to a look-alike site after a successful sign-in.safe_nextabove accepts only a local path: no scheme, no host, no leading//(a protocol-relative URL) and no backslash (some browsers treat\as/). -
User enumeration. The login view answers the same status and body for "unknown user" and "wrong password", and verifies against
DUMMY_HASHfor unknown users so that the two cases also take the same time. -
CSRF. A cookie-authenticated
POST /loginandPOST /logoutare exposed to cross-site request forgery. The CSRF protection page of this section shows how to require a token on every unsafe request; in the application above it would be switched on withconfig.set_default_csrf_options(require_csrf=True). -
Transport. A sign-in cookie sent over plain HTTP can be stolen on the network. Set
secure=Truein production and terminate TLS in front of the application (the deployment page of this section covers proxies andX-Forwarded-Proto). -
Generic 404 on mismatch. An
is_authenticated=Trueview that has no fallback answers 404 to anonymous visitors. That is often desirable (it does not reveal the page exists) but surprises people who expected a redirect to the login page. Register the fallback or usepermissionplus the Forbidden View, which is exactly whatshelf_editdoes.
AuthTktCookieHelper Options
The constructor has one required argument, secret, and a dozen optional ones. Defaults are those of Pyramid 2.1.
| Argument | Default | Meaning and advice |
|---|---|---|
|
required |
Signs the ticket. Use a long random value, unique to this subsystem (see The Secret-Sharing Warning). |
|
|
Name of the cookie. Give each application a distinct name when several share a domain. |
|
|
Send the cookie only over HTTPS. Set it to |
|
|
Sets the |
|
|
The |
|
|
Seconds for which a newly issued ticket is valid. |
|
|
Seconds after which the ticket is re-issued on a request that needs authentication, giving a sliding expiry
( |
|
|
Lifetime of the cookie, in seconds. Without it the cookie is a session cookie that vanishes when the browser
closes; with it the sign-in survives restarts (a "remember me"). It is distinct from |
|
|
Path scope of the cookie. |
|
|
Cookie domain control. |
|
|
Ties the ticket to the client IP. The documentation does not recommend it for IPv6, and clients behind mobile networks change address; it breaks more than it protects. |
|
|
Hash used for the ticket signature. Changed from |
|
|
Logs the steps of authentication to the Pyramid debug logger. |
remember(request, userid, max_age=None, tokens=()) accepts a list of tokens: short strings that match
^[A-Za-z][A-Za-z0-9+_-]*$ and travel inside the signed ticket, readable again from identity['tokens']. They are a
cheap place for coarse roles ("staff") that should not need a database round trip, with two limits: they are signed but
not encrypted (the user can read them), and they stay valid until the ticket expires, so a revoked role lingers.
User ids may be str, int or bytes; other types are converted to str with a RuntimeWarning. The ticket, like
any signed cookie, proves origin but hides nothing: put no secrets in it.
A signed ticket cannot be revoked by the server before it expires, because the server holds no state. The only server
side switches are the database check in identity (a deleted or disabled user stops working immediately, as in
load_identity above) and a short timeout. If you need "log this user out of all devices", store a per-user value
(a password-change counter, for example) in the ticket’s tokens or userdata and compare it to the database, or use
the session helper with a server-side session store.
Source: pyramid.authentication (AuthTktCookieHelper) and What’s New in Pyramid 2.0
A Policy Backed by the Session
SessionAuthenticationHelper stores the user id under the key auth.userid (the prefix argument changes the
auth. part) in whatever session the application has configured. Its remember and forget return empty lists
because the state travels through the session cookie, not through headers of their own.
from pyramid.authentication import SessionAuthenticationHelper
from pyramid.session import SignedCookieSessionFactory
class SessionPolicy:
def __init__(self, store):
self.helper = SessionAuthenticationHelper()
self.store = store
self.identity_cache = RequestLocalCache(self.load_identity)
def load_identity(self, request):
userid = self.helper.authenticated_userid(request)
return None if userid is None else self.store.get(userid)
def identity(self, request):
return self.identity_cache.get_or_create(request)
def authenticated_userid(self, request):
user = self.identity(request)
return None if user is None else user.id
def permits(self, request, context, permission):
if self.identity(request) is None:
return Denied("anonymous")
return Allowed("signed in")
def remember(self, request, userid, **kw):
return self.helper.remember(request, userid, **kw)
def forget(self, request, **kw):
return self.helper.forget(request, **kw)
def session_login(request):
user = request.user_store.by_name(request.POST.get("username", ""))
if not check_password(user, request.POST.get("password", "")):
request.response.status = 401
return {"error": "invalid credentials"}
request.session.invalidate() # new session: no session fixation
headers = remember(request, user.id) # writes auth.userid into it
return HTTPSeeOther(location="/", headers=headers)
def session_logout(request):
request.session.invalidate()
return HTTPSeeOther(location="/")
def session_me(request):
return {"user": request.authenticated_userid, "session": dict(request.session)}
def make_session_app(store):
with Configurator() as config:
config.set_security_policy(SessionPolicy(store))
config.set_session_factory(
SignedCookieSessionFactory(secrets.token_hex(32), httponly=True, samesite="Lax")
)
config.add_request_method(lambda request: store, "user_store", reify=True)
config.add_route("login", "/login")
config.add_route("logout", "/logout")
config.add_route("me", "/me")
config.add_view(session_login, route_name="login", request_method="POST", renderer="json")
config.add_view(session_logout, route_name="logout", request_method="POST")
config.add_view(session_me, route_name="me", renderer="json")
return config.make_wsgi_app()
s_app = TestApp(make_session_app(store))
s_app.post("/login", {"username": "ada", "password": ada_password}, status=303)
print(s_app.get("/me").json)
s_app.post("/logout", status=303)
print(s_app.get("/me").json)
Source: pyramid.authentication (SessionAuthenticationHelper) and Pyramid: Sessions
Three things decide whether this variant is safe.
-
Session fixation. If a login keeps the pre-login session, an attacker who planted that session identifier in the victim’s browser is signed in as the victim afterwards. Calling
request.session.invalidate()beforerememberdiscards the old session data; the values written afterwards (auth.useridhere) go into a fresh session that the response sets as a new cookie. Run on Pyramid 2.1, the newSet-Cookievalue carried both theauth.useridand any data stored after the invalidation. -
What the cookie contains. The default
SignedCookieSessionFactorykeeps the whole session in the cookie, signed but readable, serialised as JSON since Pyramid 2.0, limited to roughly 4 KB. Nobody can forge a user id, but the data is visible and a captured cookie works until it times out. The sessions page of this section covers the options and the server-side alternatives. -
Revocation. A signed-cookie session, like an auth ticket, cannot be revoked server-side. With a server-side session factory (Redis, a database) clearing the stored session signs the user out everywhere at once, which is the main reason to prefer the session helper in larger systems.
Loading the User Once per Request
request.identity is evaluated each time it is read, and a security policy is consulted from several places in one
request: the permission check before the view, request.is_authenticated in a template, has_permission in a loop that
renders Edit buttons. If identity() queries the database, an uncached policy queries it on every one of those reads.
Pyramid gives two idiomatic ways to avoid that.
-
pyramid.request.RequestLocalCache(new in 2.0), used in the policies above, keeps one value per request in a weak dictionary and releases it when the request finishes. It can wrap a function with@RequestLocalCache()or be created with a creator and queried throughget_or_create(request), which is the form that fits a class. Itsclear(request)method (reachable through thecacheattribute of a decorated function) drops the cached value, for example after the user changes their name. -
A reified request method,
config.add_request_method(get_user, 'user', reify=True), computesrequest.useronce per request and stores it on the request. This is the style of the Pyramid tutorials and the cookiecutter, and the policy’sidentitysimply returnsrequest.user. The reified property also gives templates and views a convenientrequest.userattribute that is typed to your model, whererequest.identityis opaque.
The two are measurable. The example below counts the lookups in both styles for a view that touches the identity four times.
class NoCachePolicy(ShelvesSecurityPolicy):
def identity(self, request): # bypasses the cache
return self.load_identity(request)
class ReifiedPolicy(ShelvesSecurityPolicy):
def identity(self, request):
return request.user # the reified request method
def touch_identity_four_times(request):
request.identity, request.identity, request.is_authenticated, request.has_permission("edit")
return {"ok": True}
def count_lookups(policy_factory, reify_user=False):
counting = UserStore()
counting.add("ada", "ada", ada_password)
with Configurator() as config:
policy = policy_factory(secrets.token_hex(32), counting, secure=False)
config.set_security_policy(policy)
if reify_user:
config.add_request_method(policy.load_identity, "user", reify=True)
config.add_route("t", "/t")
config.add_route("login", "/login")
config.add_view(touch_identity_four_times, route_name="t", renderer="json")
config.add_view(
lambda request: HTTPSeeOther("/", headers=remember(request, "ada")),
route_name="login",
)
client = TestApp(config.make_wsgi_app())
client.get("/login", status=303)
counting.lookups = 0
client.get("/t")
return counting.lookups
print("no cache :", count_lookups(NoCachePolicy))
print("RequestLocal :", count_lookups(ShelvesSecurityPolicy))
print("reified method:", count_lookups(ReifiedPolicy, reify_user=True))
The run printed 4, 1 and 1: without any caching the identity was loaded four times in one request (two reads of
request.identity, one through request.is_authenticated and one through request.has_permission), and with either
caching strategy once. For a request that reads a database over a network, that is the difference between one round
trip and four, and the number grows with every template and helper that asks "who is this?".
Source: pyramid.request (RequestLocalCache, add_request_method) and What’s New in Pyramid 2.0
Do not cache across requests. A module-level dictionary of identities, or an lru_cache on the lookup, survives a
password change, a disabled account or a role downgrade. Cross-request caching belongs in a store with explicit
invalidation, never in a plain variable shared by threads.
Password Hashing
Pyramid takes no position on password storage, so the choice is yours and it matters more than anything else on this page. The rules are the same as in the other frameworks of this site: never store or log a plaintext password; use a slow, salted, memory-hard or adaptive hash designed for passwords (Argon2id, scrypt, bcrypt or PBKDF2), never a plain digest such as SHA-256; compare in constant time; re-hash when the parameters get stale. The OWASP Password Storage Cheat Sheet is the usual reference for current cost parameters, and the library defaults below follow it closely.
| Library | Strengths | Watch out for |
|---|---|---|
|
Argon2id by default, the algorithm recommended by the Password Hashing Competition and OWASP. The hash string embeds
algorithm, parameters and salt, so |
Needs a compiled wheel (available for all common platforms). The cost parameters ( |
|
Ubiquitous and battle-tested; the cost factor is |
Only the first 72 bytes of the password count. bcrypt 5.0 raises |
|
No dependency. |
You must manage salt, parameters, encoding and constant-time comparison yourself. Prefer a library unless you cannot add one. |
The block below runs Argon2 and bcrypt side by side with random test passwords.
import bcrypt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
pw = secrets.token_urlsafe(12)
# Argon2id: the parameters travel inside the encoded hash.
ph = PasswordHasher()
encoded = ph.hash(pw)
print(encoded.split("$")[1], encoded.count("$"))
print(ph.verify(encoded, pw))
try:
ph.verify(encoded, pw + "x")
except VerifyMismatchError:
print("mismatch detected")
# check_needs_rehash is True after you raise the cost: upgrade on the next login.
stronger = PasswordHasher(time_cost=ph.time_cost + 1)
print("rehash needed:", stronger.check_needs_rehash(encoded))
# bcrypt: bytes in, bytes out; 72-byte limit.
digest = bcrypt.hashpw(pw.encode(), bcrypt.gensalt())
print(bcrypt.checkpw(pw.encode(), digest))
try:
bcrypt.hashpw(b"a" * 80, bcrypt.gensalt())
except ValueError as exc:
print("bcrypt:", exc)
The login view of the first example already applies these rules: it compares against a dummy hash for unknown users
(so unknown and wrong passwords take about as long), upgrades hashes whose parameters are out of date
(check_needs_rehash) at the moment the plaintext is known, and returns the same error for both failures. What it
does not do, and a production system also needs, is throttle repeated failures per account and per client address
(a reverse proxy, a rate-limiting middleware or a counter in Redis; see
Redis), require a minimum length rather than composition rules, and offer a second
factor. Those are product decisions that the framework deliberately leaves open. For delegating authentication to an
identity provider altogether, see OAuth 2.0 and OpenID Connect.
Basic and Token Authentication for APIs
A JSON API usually has no cookies: each request carries its credentials, so the policy is stateless and remember
and forget return empty lists. Pyramid supplies one helper, extract_http_basic_credentials(request), which parses
an Authorization: Basic ... header into a HTTPBasicCredentials named tuple (username, password) or returns
None for a missing, malformed or non-Basic header. It tries UTF-8 first and falls back to Latin-1 for the decoded
credentials. Bearer tokens (RFC 6750) need no helper: split the header and look the token up.
The example accepts both schemes and answers anonymous requests with 401 plus a WWW-Authenticate challenge, as
RFC 9110 requires, while an authenticated user who lacks a permission gets 403. Tokens are generated with the
secrets module and stored hashed, like passwords, so a database leak does not leak working tokens; a fast SHA-256 is
appropriate here because the token is already 256 bits of randomness (unlike a human-chosen password).
import hashlib
from pyramid.authentication import extract_http_basic_credentials
from pyramid.httpexceptions import HTTPUnauthorized
def token_digest(token):
return hashlib.sha256(token.encode()).hexdigest()
class ApiPolicy:
def __init__(self, store, tokens):
self.store = store
self.tokens = tokens # sha256 hex digest -> user id
self.identity_cache = RequestLocalCache(self.load_identity)
def load_identity(self, request):
creds = extract_http_basic_credentials(request)
if creds is not None:
user = self.store.by_name(creds.username)
return user if check_password(user, creds.password) else None
scheme, _, token = request.headers.get("Authorization", "").partition(" ")
if scheme.lower() == "bearer" and token:
userid = self.tokens.get(token_digest(token.strip()))
return None if userid is None else self.store.get(userid)
return None
def identity(self, request):
return self.identity_cache.get_or_create(request)
def authenticated_userid(self, request):
user = self.identity(request)
return None if user is None else user.id
def permits(self, request, context, permission):
if self.identity(request) is None:
return Denied("no valid credentials")
return Allowed("authenticated API client")
def remember(self, request, userid, **kw):
return [] # stateless: nothing to remember
def forget(self, request, **kw):
return []
def api_forbidden(request):
if request.is_authenticated:
request.response.status = 403
return {"error": "forbidden"}
return HTTPUnauthorized(
json_body={"error": "authentication required"},
headers={"WWW-Authenticate": 'Bearer realm="shelves", charset="UTF-8"'},
)
def api_shelves(request):
return {"owner": request.authenticated_userid, "shelves": ["to-read", "favourites"]}
issued_token = secrets.token_urlsafe(32) # shown to the user once
api_policy = ApiPolicy(store, {token_digest(issued_token): "ada"})
with Configurator() as config:
config.set_security_policy(api_policy)
config.add_route("api_shelves", "/api/shelves")
config.add_view(api_shelves, route_name="api_shelves", permission="read", renderer="json")
config.add_forbidden_view(api_forbidden, renderer="json")
api = TestApp(config.make_wsgi_app())
r = api.get("/api/shelves", status=401)
print(r.status, r.headers["WWW-Authenticate"])
print(api.get("/api/shelves", headers={"Authorization": f"Bearer {issued_token}"}).json)
print(api.get("/api/shelves", headers={"Authorization": "Bearer " + secrets.token_urlsafe(32)},
status=401).status)
from base64 import b64encode
basic = b64encode(f"ada:{ada_password}".encode()).decode()
print(api.get("/api/shelves", headers={"Authorization": f"Basic {basic}"}).json)
Source: pyramid.authentication (extract_http_basic_credentials), RFC 9110: HTTP Semantics, RFC 7617: The "Basic" HTTP Authentication Scheme and RFC 6750: Bearer Token Usage
Notes on this design:
-
Basic credentials are verified with the full password hash on every request, which is deliberately slow. Use Basic for scripts and administration, and issue bearer tokens (or JWTs, see Flask: JWT & API Authentication and FastAPI: OAuth2 Password Flow & JWT for the validation rules, which are the same in Pyramid) for anything called often.
-
Basic and bearer credentials must travel over HTTPS only. Neither is encrypted; Basic is merely Base64.
-
Never put tokens in URLs, where logs and
Refererheaders record them. -
Because no cookie is involved, the CSRF attack that threatens cookie-based sign-in does not apply to these requests. The converse also holds: do not let a cookie-authenticated browser session and a token API share endpoints without thinking about which attacks each admits. Browsers calling the API from another origin also need CORS, covered at CORS.
-
The policy distinguishes
401(no valid credentials: "who are you?") from403(valid credentials but not allowed: "I know you, and no"). The Forbidden View receives both situations, so it branches onrequest.is_authenticated. The errors page of this section showsadd_forbidden_viewin detail. -
A real API would add a
/tokensendpoint behind password sign-in that creates, lists and revokes tokens, with an expiry and a scope per token. Pyramid has nothing to do with that part beyond the policy.
The Secret-Sharing Warning
The official documentation carries a section titled Admonishment Against Secret-Sharing. Both the
AuthTktCookieHelper and the SignedCookieSessionFactory sign their payloads with a secret, and the temptation to
define one secret setting and hand it to both is strong. Don’t. When two subsystems sign different payload shapes with
the same key, an attacker who can make the application sign text they choose through one subsystem (a value echoed into
a signed cookie, for instance) may be able to reuse that signed text as a valid payload of the other one. The
documentation’s conclusion is that sharing a secret may reduce the security of signing to zero, and its rule is
simple: keys must not be reused across contexts where an attacker can provide chosen plaintext.
In practice that means a separate, long, random, never-committed secret per signing subsystem: one for the auth
ticket, one for the session factory, and, if you add them, separate ones for password-reset links, e-mail
confirmation tokens and anything else signed with hmac or itsdangerous. The next example loads them from the
environment, refuses to start when one is missing in production, refuses an accidental duplicate, and shows the
development fallback honestly: a random value generated at start-up, which logs everyone out on every restart and does
not work with several worker processes (each would generate its own).
import os
def load_secrets(environ=os.environ, production=True):
names = ("SHELVES_AUTH_SECRET", "SHELVES_SESSION_SECRET")
values = {}
for name in names:
value = environ.get(name)
if not value:
if production:
raise RuntimeError(f"{name} must be set in production")
value = secrets.token_hex(32) # development only: per process
if len(value) < 32:
raise RuntimeError(f"{name} is too short; use at least 32 random characters")
values[name] = value
if len(set(values.values())) != len(values):
raise RuntimeError("each subsystem needs its own secret; do not share them")
return values
# Development: random secrets, distinct by construction.
dev = load_secrets({}, production=False)
print(sorted(dev), len(set(dev.values())))
# Production: a missing or shared secret stops the application at start-up.
for env in ({}, {"SHELVES_AUTH_SECRET": "x" * 40, "SHELVES_SESSION_SECRET": "x" * 40}):
try:
load_secrets(env)
except RuntimeError as exc:
print("refused:", exc)
Source: Pyramid: Security (Admonishment Against Secret-Sharing) and OWASP: Cryptographic Storage Cheat Sheet
Generate a production secret once, for example with python -c "import secrets; print(secrets.token_hex(32))", store
it in the secret manager of your platform and inject it as an environment variable. Rotating the auth secret signs
every user out (their tickets no longer verify), so plan rotation for a quiet moment or run the old and new secret side
by side in a custom policy that tries both in identify. The settings page of this section explains how application
code reads settings and the environment, and the deployment page how a secret reaches the process.
Migrating from Authentication and Authorization Policies
Before Pyramid 2.0 an application configured two objects with config.set_authentication_policy() and
config.set_authorization_policy(). Many tutorials, books and answers on the web still show them. They are legacy in
Pyramid 2.1: deprecated, still functional (the official notes say they "will continue to function normally for the
time being") and liable to be removed in a later release. New code should use the security policy API shown on this page.
The following is what the legacy setup looked like. The block runs it with warnings recorded, to show what Pyramid 2.1 says about it.
import warnings
from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy
def groupfinder(userid, request):
# the "callback": None for unknown users, otherwise a list of principals
return ["group:editors"] if userid == "ada" else None
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
with Configurator() as legacy:
legacy.set_authentication_policy(
AuthTktAuthenticationPolicy(secrets.token_hex(32), callback=groupfinder)
)
legacy.set_authorization_policy(ACLAuthorizationPolicy())
legacy.add_route("x", "/x")
legacy.add_view(lambda request: {"user": request.authenticated_userid},
route_name="x", renderer="json")
legacy_app = TestApp(legacy.make_wsgi_app())
legacy_app.get("/x")
print(sorted({w.category.__name__ for w in caught}))
print(sorted({str(w.message).split(".")[0][:70] for w in caught if "deprecated" in str(w.message)}))
Source: What’s New in Pyramid 2.0 (Upgrading Authentication/Authorization) and What’s New in Pyramid 2.1
The equivalences in the upgrade notes:
| Legacy (1.x, deprecated in 2.0) | Security policy (2.0 and later) |
|---|---|
|
One |
|
|
|
|
|
|
|
|
|
No equivalent. |
|
|
The |
Whatever your |
|
Deprecated, no direct equivalent: use |
|
Deprecated; under a security policy it always returns |
|
Replaced by the |
|
Import them from |
|
Deprecated; works only with the legacy authorization policy and not at all with a security policy. |
The migration itself has three steps. Replace the two policies by one class whose identity does what the old
unauthenticated_userid plus callback did (verify the id, load the user, return None when the callback returned
None), whose permits builds a principal list from the identity and calls ACLHelper().permits(context,
principals, permission), and whose remember / forget delegate to the helper. Switch the ACL imports to
pyramid.authorization. Finally, replace effective_principals and unauthenticated_userid uses by identity
and authenticated_userid.
Applications that cannot migrate everything at once need do nothing: when the legacy pair is configured, Pyramid itself
wraps it in a compatibility policy (pyramid.security.LegacySecurityPolicy), so request.identity,
remember() and forget() keep working. A third-party package that supplies its own authentication and
authorization policy objects can be adapted by hand with a shim security policy, which the upgrade notes also show. The version below fixes one detail: the official snippet’s forget is written against the
authorization policy (self.authz_policy.forget(...)), which has no such method; it must call the authentication
policy.
class ShimSecurityPolicy:
def __init__(self, authn_policy, authz_policy):
self.authn_policy = authn_policy
self.authz_policy = authz_policy
def identity(self, request):
return self.authn_policy.authenticated_userid(request)
def authenticated_userid(self, request):
return self.authn_policy.authenticated_userid(request)
def permits(self, request, context, permission):
principals = self.authn_policy.effective_principals(request)
return self.authz_policy.permits(context, principals, permission)
def remember(self, request, userid, **kw):
return self.authn_policy.remember(request, userid, **kw)
def forget(self, request, **kw):
return self.authn_policy.forget(request)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
shim = ShimSecurityPolicy(
AuthTktAuthenticationPolicy(secrets.token_hex(32), callback=groupfinder),
ACLAuthorizationPolicy(),
)
with Configurator() as config:
config.set_security_policy(shim)
config.add_route("x", "/x")
config.add_view(lambda request: {"ok": True}, route_name="x", renderer="json")
TestApp(config.make_wsgi_app()).get("/x")
print("shim app built and served a request")
Source: What’s New in Pyramid 2.0 (Upgrading Authentication/Authorization) and What’s New in Pyramid 2.1
When a book, a blog post or a Stack Overflow answer shows AuthTktAuthenticationPolicy(...), groupfinder,
authenticated_userid(request) as a free function from pyramid.security, or Everyone imported from
pyramid.security, translate it with the table above. The Pyramid tutorials were rewritten for 2.0 and use the
security policy throughout, so the official tutorials are the reliable source. The whats-changed page of this section
collects every 1.x to 2.x difference in one place.
Testing the Policy
Because the policy is a plain class, most of it can be tested without a web server: construct it with a fake store and
call identity with a pyramid.testing.DummyRequest. For a sign-in flow, drive the real application with
webtest.TestApp, as every example on this page does; TestApp keeps a cookie jar and so behaves like a browser. Set
secure=False in the test settings (the example reads auth.secure), because a Secure cookie is not returned over
the http://localhost that WebTest uses. The configurator method config.testing_securitypolicy(userid=..., identity=..., permissive=...) (available on the
Configurator that pyramid.testing.testConfig yields) registers a fake policy for unit tests of views, with
configurable answers; the testing page of this section covers it, and the fixtures that create a signed-in client.
Pitfalls at a Glance
| Pitfall | What to do |
|---|---|
|
Protected views run for everyone and no error is reported. Assert in a test that an anonymous request to a protected view is refused. |
Reading |
Cache it per request with |
Trusting the cookie without checking the database |
Verify in |
The same secret for tickets, sessions and other signing |
One long random secret per subsystem, from the environment, never in the repository. |
|
Set |
|
A ticket that never expires is a permanent credential. Set |
Login without |
The old session survives the sign-in: session fixation. |
Unvalidated |
Accept only local paths (open redirect). |
Different errors or timings for "no such user" and "wrong password" |
Same response, and verify against a dummy hash. |
Fast hashes for passwords |
Argon2id or bcrypt through a maintained library, with |
Following 1.x tutorials |
Translate |
Where This Leads
This page decides who the user is. Which resources that user may touch is the subject of the authorization page of
this section: view permissions, set_default_permission, ACLs on resources, ACLHelper.permits and per-object
rules. The sessions and flash messages page covers the session factory that the session helper relies on and the
flash queues that confirm "signed in" to the user. The CSRF protection page adds the token that cookie-based sign-in
needs on every state-changing request, and the forms page shows the login form itself. The SQLAlchemy page replaces
the in-memory UserStore with a real table whose session is closed with the request. For the same problem in the
other frameworks of this site, see Flask,
Tornado and Django.