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.

sequenceDiagram participant B as Browser participant V as Login view participant P as Security policy participant H as AuthTktCookieHelper participant S as Protected view B->>V: POST /login with username and password V->>V: look up the user and verify the password hash V->>P: remember(request, userid) P->>H: remember(request, userid) H-->>P: Set-Cookie headers P-->>V: headers V-->>B: 303 See Other, Set-Cookie: auth ticket B->>S: GET /shelves/ada/edit with the cookie S->>P: permits(request, context, permission) P->>H: identify(request) H-->>P: userid from the verified ticket P->>P: identity loaded once and cached for the request P-->>S: Allowed or Denied S-->>B: 200 OK, or the Forbidden View on Denied
Figure 1. Login, remember, policy and protected view

The five methods of pyramid.interfaces.ISecurityPolicy:

Method Called by Must return

identity(request)

request.identity, and your own permits()

Any object that describes the current user (a user id string, a dict, an ORM object), or None when nobody is signed in. The shape is entirely up to the application.

authenticated_userid(request)

request.authenticated_userid, request.is_authenticated, the is_authenticated view predicate

A string user id for a trusted and verified user, or None. When it is None, request.is_authenticated is False.

permits(request, context, permission)

View lookup when a view has a permission, and request.has_permission()

An instance of pyramid.security.Allowed or pyramid.security.Denied. Both evaluate as booleans and carry a msg attribute that explains the decision.

remember(request, userid, **kw)

pyramid.security.remember(request, userid, **kw)

A list of header tuples (usually Set-Cookie) that the login view adds to its response. The meaning of **kw is agreed between the caller and the policy.

forget(request, **kw)

pyramid.security.forget(request, **kw)

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

request.identity

Calls policy.identity(request). New in 2.0. Pyramid does not cache the result; every access calls your method again, which is why the user-loading section below matters.

request.authenticated_userid

Calls policy.authenticated_userid(request).

request.is_authenticated

True when authenticated_userid is not None.

request.has_permission(permission, context=None)

Calls policy.permits(request, context, permission); context defaults to request.context. Use it to decide inside a view whether to show an Edit button.

is_authenticated=True (or False) on add_view / @view_config

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.

pyramid.security.remember(request, userid, **kw), pyramid.security.forget(request, **kw)

Return the headers of the policy’s remember / forget, or an empty list when no policy is registered.

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

pyramid.authentication.AuthTktCookieHelper

A signed "auth ticket" cookie (the mod_auth_tkt format): timestamp, user id, optional tokens and user data, with an HMAC. Nothing is stored on the server.

You want stateless, horizontally scalable sign-in without a session store, or you share the cookie across sibling subdomains.

pyramid.authentication.SessionAuthenticationHelper

The Pyramid session (request.session['auth.userid']). It needs a session factory, so the security of the sign-in is the security of the session (see the sessions and flash messages page of this section).

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.

pyramid.authentication.extract_http_basic_credentials(request)

The Authorization: Basic header. A function, not a class, returning a (username, password) named tuple or None.

HTTP Basic for APIs and scripts (always over HTTPS).

request.environ.get('REMOTE_USER')

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

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.

A user store that counts its lookups, and Argon2 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

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.

The security policy, with the identity cached per request
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) returns None when there is no cookie, when the signature does not verify and when the ticket is older than timeout; otherwise it returns a dict with userid, tokens, userdata and timestamp. It proves only that the cookie was issued by someone holding the secret. It does not prove the user still exists, which is why load_identity goes back to the store.

  • permits here 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 and ACLHelper.

  • forget accepts **kw because the interface says so, but the helper’s forget(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.

Login and logout views, a protected view and the forbidden view
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.

Signing in, calling a protected view, signing out
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 next parameter 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_next above 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_HASH for unknown users so that the two cases also take the same time.

  • CSRF. A cookie-authenticated POST /login and POST /logout are 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 with config.set_default_csrf_options(require_csrf=True).

  • Transport. A sign-in cookie sent over plain HTTP can be stolen on the network. Set secure=True in production and terminate TLS in front of the application (the deployment page of this section covers proxies and X-Forwarded-Proto).

  • Generic 404 on mismatch. An is_authenticated=True view 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 use permission plus the Forbidden View, which is exactly what shelf_edit does.

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

secret

required

Signs the ticket. Use a long random value, unique to this subsystem (see The Secret-Sharing Warning).

cookie_name

auth_tkt

Name of the cookie. Give each application a distinct name when several share a domain.

secure

False

Send the cookie only over HTTPS. Set it to True in production. The default is False only so that local development works over HTTP.

http_only

False

Sets the HttpOnly flag, hiding the cookie from JavaScript. Turn it on: it limits what an XSS bug can steal.

samesite

'Lax'

The SameSite attribute. Lax stops the browser sending the cookie on cross-site sub-requests and POSTs; None turns the attribute off (explicitly None in Python, not the string). It is a defence in depth, not a substitute for CSRF tokens.

timeout

None

Seconds for which a newly issued ticket is valid. None means it never expires, which is rarely what you want.

reissue_time

None

Seconds after which the ticket is re-issued on a request that needs authentication, giving a sliding expiry (timeout of 1200 and reissue_time of about a tenth of it is the documented rule of thumb). 0 re-issues on every authenticated request. Setting it lower than timeout is meaningful; higher means the ticket is never re-issued.

max_age

None

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 timeout, which limits the ticket inside the cookie. remember(request, userid, max_age=...) overrides it per login.

path

'/'

Path scope of the cookie.

domain, parent_domain, wild_domain

None, False, True

Cookie domain control. domain pins it to one domain (and is incompatible with the other two); parent_domain=True issues it for the parent domain (.example.com for www.example.com), which shares the sign-in across sibling hosts and therefore widens the attack surface; wild_domain (default) issues it for the current host’s wildcard domain.

include_ip

False

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.

hashalg

'sha512'

Hash used for the ticket signature. Changed from md5 to sha512 in Pyramid 2.0. Changing it invalidates every existing cookie, so users must sign in again.

debug

False

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.

A session-based policy with its own session secret
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() before remember discards the old session data; the values written afterwards (auth.userid here) go into a fresh session that the response sets as a new cookie. Run on Pyramid 2.1, the new Set-Cookie value carried both the auth.userid and any data stored after the invalidation.

  • What the cookie contains. The default SignedCookieSessionFactory keeps 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.

  1. 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 through get_or_create(request), which is the form that fits a class. Its clear(request) method (reachable through the cache attribute of a decorated function) drops the cached value, for example after the user changes their name.

  2. A reified request method, config.add_request_method(get_user, 'user', reify=True), computes request.user once per request and stores it on the request. This is the style of the Pyramid tutorials and the cookiecutter, and the policy’s identity simply returns request.user. The reified property also gives templates and views a convenient request.user attribute that is typed to your model, where request.identity is opaque.

The two are measurable. The example below counts the lookups in both styles for a view that touches the identity four times.

Counting database lookups per request
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

argon2-cffi (argon2.PasswordHasher)

Argon2id by default, the algorithm recommended by the Password Hashing Competition and OWASP. The hash string embeds algorithm, parameters and salt, so verify needs only the hash. check_needs_rehash reports stale parameters.

Needs a compiled wheel (available for all common platforms). The cost parameters (time_cost, memory_cost, parallelism) are tunable; measure on your hardware.

bcrypt

Ubiquitous and battle-tested; the cost factor is rounds in gensalt.

Only the first 72 bytes of the password count. bcrypt 5.0 raises ValueError for longer input instead of silently truncating (verified on 5.0.0); older releases truncated silently. Pre-hashing with SHA-256 and base64 is a common workaround. bcrypt is not memory-hard.

hashlib.scrypt, hashlib.pbkdf2_hmac (standard library)

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.

Hashing, verifying and rehashing 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).

A stateless API policy: Basic credentials or a bearer token
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)

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 Referer headers 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?") from 403 (valid credentials but not allowed: "I know you, and no"). The Forbidden View receives both situations, so it branches on request.is_authenticated. The errors page of this section shows add_forbidden_view in detail.

  • A real API would add a /tokens endpoint 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).

Separate secrets, read from the environment
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.

The legacy setup, still working and reporting deprecations
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)

set_authentication_policy() and set_authorization_policy()

One set_security_policy() (or Configurator(security_policy=...)).

AuthTktAuthenticationPolicy

AuthTktCookieHelper inside your policy.

SessionAuthenticationPolicy

SessionAuthenticationHelper inside your policy.

BasicAuthAuthenticationPolicy

extract_http_basic_credentials(request) plus your own check.

RemoteUserAuthenticationPolicy

request.environ.get('REMOTE_USER').

RepozeWho1AuthenticationPolicy

No equivalent.

ACLAuthorizationPolicy

pyramid.authorization.ACLHelper().permits(...) called from your permits (next page).

The callback / groupfinder returning principals

Whatever your identity() loads; derive principals inside permits() if you use ACLs.

request.unauthenticated_userid

Deprecated, no direct equivalent: use request.authenticated_userid or request.identity. Under a security policy it returns the same value as authenticated_userid.

request.effective_principals

Deprecated; under a security policy it always returns [Everyone] (the one-element list with the Everyone principal). Compute principals in permits().

effective_principals view predicate

Replaced by the is_authenticated predicate or a custom predicate.

Allow, Deny, Everyone, Authenticated, ALL_PERMISSIONS, DENY_ALL, ACLAllowed, ACLDenied from pyramid.security

Import them from pyramid.authorization. The old locations still work and are deprecated.

principals_allowed_by_permission

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.

A shim that lets the old policies run under the new interface
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

set_security_policy was never called

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 request.identity repeatedly

Cache it per request with RequestLocalCache or a reified request method.

Trusting the cookie without checking the database

Verify in identity() that the user still exists and is active.

The same secret for tickets, sessions and other signing

One long random secret per subsystem, from the environment, never in the repository.

secure=False and no http_only in production

Set secure=True, http_only=True, keep samesite='Lax' and terminate TLS in front of the application.

timeout=None

A ticket that never expires is a permanent credential. Set timeout, and reissue_time for sliding sessions.

Login without session.invalidate() (session helper)

The old session survives the sign-in: session fixation.

Unvalidated next after login

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

Following 1.x tutorials

Translate groupfinder, effective_principals and pyramid.security ACL imports with the migration table.

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.