Sessions & Flash Messages
|
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 (with pyramid_jinja2 2.10.1, WebTest 3.0.7, WebOb 1.8.11 and
pyramid_nacl_session 1.0.0) on Python 3.13, checked on 2026-10-07. Every Python example was executed with
webtest.TestApp, and every name was checked against the official narrative chapter on sessions, the
pyramid.session and pyramid.interfaces API pages and the installed package source. The running example is the
Bookshelf Shelves scenario: a catalogue of public shelves of books where a visitor keeps a few preferences, creates
shelves, and is told "Shelf created" exactly once after the redirect that follows the form.
HTTP is stateless: the server forgets a request as soon as it has answered it. A session is the namespace that
survives from one request to the next for one visitor. Pyramid defines it as an interface and nothing more. The core
does not ship a database of sessions, a session id scheme or a clean-up job. It defines pyramid.interfaces.ISession
(a dictionary-like object with a few extra attributes and methods), a hook that turns a session factory into the lazy
attribute request.session, and one small implementation of the interface that keeps the whole session inside a signed
cookie. Everything else, such as Redis-backed sessions, encrypted cookies or sessions stored in a SQL table, is an
add-on or about thirty lines of your own code, and the application code (request.session['key'], request.session.flash())
does not change when the implementation does.
This page covers the interface, the signed-cookie factory and every one of its options, what the signature does and
does not protect, the JSON-only rule introduced in Pyramid 2.0, the 4 KB ceiling, the changed() gotcha,
invalidate(), expiry, flash messages (a queue of one-shot messages stored in the session), the alternative session
factories with their maintenance status, a complete custom server-side factory, and testing. The same topic in the
other frameworks of this site is in Sessions, Cookies & Flashing
for Flask, Authentication & Signed Cookies for Tornado and
Sessions, Cookies, Messages & Signing for Django. How
cookies are read and written on the request and the response is in
Request & Response, and the settings that carry values such as the
session secret are in Settings, Environment & .ini Files.
The Session Machinery
Three pieces cooperate, and none of them is magic.
-
A session factory is any callable that takes a request and returns an object providing
ISession. Anything implementingpyramid.interfaces.ISessionFactoryfits: a class, a function, or the result ofSignedCookieSessionFactory(...). -
The factory is registered once at configuration time, either with the
session_factoryargument of theConfiguratorconstructor or withconfig.set_session_factory(factory). -
request.sessionis a lazy attribute: the first access calls the registered factory with the request and caches the result for the rest of the request. A view that never touches it never parses a cookie, and a request without a session never produces aSet-Cookieheader.
Without a registered factory, request.session raises an AttributeError rather than a configuration error, so a
missing set_session_factory call shows up the first time a view uses a session. The next example registers the signed
cookie factory with a secret read from the environment (generated here only so that the example runs on its own) and
adds a view that remembers a preferred sort order and counts visits.
import json
import os
import secrets
from pyramid.config import Configurator
from pyramid.response import Response
from pyramid.session import SignedCookieSessionFactory
from webtest import TestApp
# In production the secret comes from the environment or a secret store, is
# identical in every worker process, and is never committed to the repository.
SESSION_SECRET = os.environ.get('SHELVES_SESSION_SECRET') or secrets.token_hex(32)
def preferences(request):
session = request.session
session['visits'] = session.get('visits', 0) + 1
if 'sort' in request.params:
session['sort'] = request.params['sort']
body = {'visits': session['visits'], 'sort': session.get('sort', 'title'), 'new': session.new}
return Response(json_body=body)
def make_app(views=None, session_factory=None, **factory_options):
"""Build a WSGI app; reused by the other examples on this page."""
if session_factory is None:
session_factory = SignedCookieSessionFactory(SESSION_SECRET, **factory_options)
config = Configurator(session_factory=session_factory)
for name, view in (views or {'preferences': preferences}).items():
config.add_route(name, '/' + name)
config.add_view(view, route_name=name)
return config.make_wsgi_app()
app = TestApp(make_app())
print(app.get('/preferences?sort=author').json)
print(app.get('/preferences').json)
print(app.get('/preferences').json)
# A different browser (a fresh cookie jar) has its own session.
print(TestApp(make_app()).get('/preferences').json)
# Without a session factory request.session does not exist.
bare = Configurator()
bare.add_route('p', '/p')
bare.add_view(preferences, route_name='p')
try:
TestApp(bare.make_wsgi_app()).get('/p')
except AttributeError as exc:
print('no factory:', type(exc).__name__)
Source: Pyramid: Sessions
The first visit reports new: True, the later ones new: False and an incremented counter, and the second browser
starts from zero. Two practical notes follow from the lazy design. First, registering two factories in the same
configuration unit is reported as a ConfigurationConflictError like any other conflicting declaration, while an
included package may still be overridden by the including application (see
Configuration & the Configurator).
Second, ISession guarantees only the dictionary interface plus created, new, changed(), invalidate(), the
three flash methods and the two CSRF-token methods; attributes such as accessed or renewed exist on the signed-cookie
implementation but are not part of the interface, so portable code should not use them.
The Signed Cookie Session Factory
pyramid.session.SignedCookieSessionFactory stores the whole session in one cookie. At the end of a request that
changed the session, a response callback serializes the triple (last access time, creation time, data), signs it
with an HMAC (SHA-512 by default, keyed with the secret, with a salt that namespaces the key) and puts the result in
a Set-Cookie header. On the next request the factory verifies the signature, deserializes the data and checks the
timeout. If the signature is wrong or the payload cannot be decoded, the cookie is dropped and the visitor simply gets
an empty session; there is no error page for a forged cookie.
The cost of this design is the first thing to understand: the cookie is signed, not encrypted. The signature proves that the content was produced by someone who knows the secret, so a visitor cannot edit their session. It does not hide the content, and the visitor (and anything on the network path without TLS, and any script that can read the cookie) can read the data. The following example reads the cookie like a browser extension would, then tampers with it.
import base64
import hashlib
import json
app = TestApp(make_app())
app.get('/preferences?sort=author')
cookie = app.cookies['session']
# value = urlsafe-base64( HMAC-SHA512 signature (64 bytes) + JSON payload )
raw = base64.urlsafe_b64decode(cookie + '=' * (-len(cookie) % 4))
digest_size = hashlib.sha512().digest_size
signature, payload = raw[:digest_size], raw[digest_size:]
last_access, created, data = json.loads(payload)
print('readable by anyone:', data)
# Editing the payload invalidates the signature, so the session is dropped.
forged = payload.replace(b'author', b'AUTHOR')
forged_cookie = base64.urlsafe_b64encode(signature + forged).decode()
attacker = TestApp(make_app())
attacker.set_cookie('session', forged_cookie)
print('forged cookie accepted?', attacker.get('/preferences').json['visits'] != 1)
# A cookie signed with another secret is rejected the same way.
other = TestApp(make_app(session_factory=SignedCookieSessionFactory(secrets.token_hex(32))))
other.set_cookie('session', cookie)
print('foreign cookie accepted?', other.get('/preferences').json['visits'] != 1)
Source: Pyramid: pyramid.session
Consequences for what belongs in the session:
-
Keep it small and non-sensitive: identifiers, preferences, a flash message, a CSRF token. Do not store passwords, personal data or anything whose disclosure would matter. The documentation says to prefer a server-side factory for anything but the most basic applications.
-
Treat the secret like a signing key. Anyone who has it can mint a session with any content, including
user_idvalues, so it must be long (the API page asks for at least 64 characters for SHA-512, which is whatsecrets.token_hex(32)produces), random, identical in every process and different from every other secret of the application. The security chapter has a warning against reusing one secret for several subsystems (admonishment against secret sharing), and thesaltparameter exists for the case where you have to share it anyway. -
A cross-site scripting hole turns into a session read: with
httponlyoff, injected JavaScript can read the cookie and so the CSRF token and any other value in it. Escape output in templates (see Templates) and sethttponly=True. -
There is no server-side revocation. A signed cookie stays valid until it times out or the secret changes, and the server cannot "log out" a stolen cookie. If you need revocation, use a server-side factory.
-
The factory has no list of fallback secrets. Changing
secretimmediately invalidates every existing session (all visitors lose their session and the flash messages in it), which is acceptable for a rotation after a leak but not for routine rotation. Graceful rotation needs a custom factory that accepts old signatures while signing with the new one; Flask’sSECRET_KEY_FALLBACKShas no direct Pyramid counterpart.
Every Option of SignedCookieSessionFactory
All parameters except secret have defaults. The table follows the API page for 2.1 and was checked against the
function signature in the installed package.
| Parameter | Default | Meaning and advice |
|---|---|---|
|
required |
HMAC key. At least 64 characters for SHA-512, random, shared by all workers, unique per purpose. Read it from the environment or a secret store. |
|
|
Name of the cookie. Change it when several applications share a host so that they do not overwrite each other; the
|
|
|
Lifetime of the cookie in seconds. |
|
|
Path attribute of the cookie. |
|
|
Domain attribute. |
|
|
Adds the |
|
|
Adds |
|
|
The |
|
|
Seconds of inactivity after which the content is discarded. |
|
|
Seconds that must pass before an access to the session re-issues the cookie (extending the timeout). |
|
|
Write the cookie even when the view raised an exception. Set it to |
|
|
HMAC algorithm; anything supported by |
|
|
Namespace mixed into the key derivation so that one secret can be used for different cookies. |
|
JSON serializer |
Object with |
A production configuration therefore looks like the following. The example prints the cookie header of a response to
show which attributes were applied, using a cookie name with the __Host- prefix, a ten-minute timeout and a
two-minute re-issue interval (the rule of thumb in the API documentation is a tenth of the timeout).
production = SignedCookieSessionFactory(
SESSION_SECRET,
cookie_name='__Host-shelves',
secure=True,
httponly=True,
samesite='Lax',
timeout=600,
reissue_time=60,
max_age=None,
set_on_exception=False,
)
app = TestApp(make_app(session_factory=production), extra_environ={'wsgi.url_scheme': 'https'})
response = app.get('/preferences?sort=author')
header = response.headers['Set-Cookie']
print(header.split('=', 1)[0])
print(sorted(part.strip() for part in header.split(';')[1:]))
Source: Pyramid: pyramid.session
The attributes are HttpOnly, Path=/, SameSite=Lax and secure. Behind a TLS-terminating proxy, make sure the
scheme reaches the application (see the proxy section in
Request & Response) because browsers, not Pyramid, decide whether to
send a Secure cookie, and a development server on plain HTTP simply never receives it back.
Using the Session Object
A session behaves like a dictionary and has a few extra members defined by ISession.
| Member | Meaning |
|---|---|
|
The usual |
|
Timestamp (seconds since the epoch) of the session’s creation. |
|
|
|
Tell the session that data was mutated in place, so it must be serialized again. |
|
Throw away all data of the session. |
|
The flash message API, covered below. |
|
Token storage used by the session-based CSRF policy; see the CSRF page of this section. |
Pyramid’s default CSRF storage policy keeps its token in the session (under a key such as _csrft_), which is one
more reason not to use the session key space carelessly: keys starting with an underscore are used by Pyramid itself
for CSRF tokens and flash queues. The page on CSRF protection later in this section covers get_csrf_token,
check_csrf_token and the cookie-based alternative.
The changed() Gotcha
Setting or deleting a key through the session (session['k'] = v, del session['k'], pop) marks the session
as changed. Mutating a value that is already stored, such as appending to a list or editing a nested dictionary,
does not: the session cannot see it. In that case you must call session.changed().
The default reissue_time=0 hides this bug in development, because merely reading the session re-issues the cookie
and the cookie is serialized from the live (mutated) data at the end of the request. The mutation is persisted by
accident, and then silently lost the day somebody sets reissue_time to a larger value or None to stop sending a
Set-Cookie on every request. The next example makes the difference visible with reissue_time=None, using a "recently
viewed shelves" list.
def view_shelf(request):
shelf_id = int(request.matchdict['id']) if request.matchdict else int(request.params['id'])
recent = request.session.get('recent')
if recent is None:
recent = request.session['recent'] = [] # assignment marks the session as changed
recent.append(shelf_id) # in-place mutation does not
if request.params.get('fixed'):
request.session.changed()
return Response(json_body=recent)
def read_recent(request):
return Response(json_body=request.session.get('recent', []))
views = {'view_shelf': view_shelf, 'read_recent': read_recent}
for fixed in ('', '&fixed=1'):
app = TestApp(make_app(views, reissue_time=None))
app.get('/view_shelf?id=1' + fixed) # first call: assigning the new list changed the session
app.get('/view_shelf?id=2' + fixed) # later calls only mutate the stored list
app.get('/view_shelf?id=3' + fixed)
print('with changed()' if fixed else 'without changed()', app.get('/read_recent').json)
Source: Pyramid: Using a Session Object
Without changed() the stored list stops at its first element, because only the first call, which assigned the key,
marked the session as changed. With changed() all three ids are kept. Calling changed() when it is not needed is
harmless, which is why the documentation says to call it whenever in doubt. The same warning is a reason to prefer
immutable values (replace the list: session['recent'] = recent + [shelf_id]).
Only JSON Data, and Not More Than About 4 KB
Since Pyramid 2.0 the session contract requires JSON-serializable values and SignedCookieSessionFactory serializes
with JSON. Before 2.0 it used pickle, which meant that anyone who obtained the secret (or any bug that let an attacker
craft a validly signed cookie) could execute arbitrary code on the server just by sending a cookie. The change was made
for that reason, and it is backward incompatible. Practical effects:
-
Values must be strings, numbers, booleans,
None, lists and dictionaries with string keys. Tuples come back as lists; dates,Decimal, sets, model instances and bytes are rejected. -
The error appears when the response is built (the cookie is written in a response callback), not at the line that stored the value, so the traceback points at the framework. Convert on the way in (
date.isoformat()), store ids instead of objects, and load the object from the database on demand. -
The cookie cannot exceed roughly 4 KB. The implementation refuses to write a value longer than 4064 characters and raises
ValueErrorin the same response callback. Base64 and the signature add overhead, so the usable payload is closer to 3 KB. Browsers silently drop oversized cookies, so the limit is also a limit of the wholeCookieheader. -
If you upgraded from an application that used the pickle serializer, old cookies no longer deserialize and are dropped, which logs everybody out once. The 2.0 upgrade notes show a temporary serializer that reads both formats; use it only for a short transition, because unpickling is exactly the risk that was removed.
import datetime
def store_date(request):
request.session['seen'] = datetime.date(2026, 10, 7) # not JSON
return Response('stored')
def store_isoformat(request):
request.session['seen'] = datetime.date(2026, 10, 7).isoformat()
request.session['pair'] = (1, 2) # a tuple becomes a list
return Response('stored')
def store_big(request):
request.session['blob'] = 'x' * 5000
return Response('stored')
def read_all(request):
return Response(json_body=dict(request.session))
views = {'store_date': store_date, 'store_isoformat': store_isoformat, 'store_big': store_big, 'read_all': read_all}
app = TestApp(make_app(views))
for name in ('store_date', 'store_big'):
try:
app.get('/' + name)
except (TypeError, ValueError) as exc:
print(name, '->', type(exc).__name__, '-', str(exc)[:55])
app.get('/store_isoformat')
print(app.get('/read_all').json)
Source: Pyramid: What’s New in Pyramid 2.0 (section "Upgrading Session Serialization")
Invalidating a Session
session.invalidate() throws away every key of the session, flash messages and CSRF token included. Use it:
-
on logout, together with forgetting the identity (see the security pages of this section);
-
on login and on any privilege change, so that data placed in the session before authentication (and the CSRF token that went with it) is not carried into the authenticated session. This is the cookie-based counterpart of the "regenerate the session id on login" rule against session fixation. With a signed cookie there is no id to fixate, but with a server-side factory the factory must issue a new id after
invalidate(); -
when a user changes their password, to drop stale state.
The signed-cookie implementation of invalidate() clears the dictionary, which also marks it changed, so the response
carries a fresh, empty signed cookie instead of deleting the cookie (a comment in the source says it probably should
unset it). The visitor therefore ends up with a valid but empty session. That is enough for correctness, but do not
rely on the cookie disappearing from the browser. The example below logs a visitor in, records a flash message while
unauthenticated, and shows that invalidating before login keeps the pre-login data out.
from pyramid.httpexceptions import HTTPSeeOther
def anonymous_visit(request):
request.session['cart_note'] = 'saved before login'
return Response('ok')
def login(request):
request.session.invalidate() # drop everything stored while anonymous
request.session['user_id'] = 42 # then record the identity
return HTTPSeeOther(location=request.route_url('whoami'))
def logout(request):
request.session.invalidate()
return HTTPSeeOther(location=request.route_url('whoami'))
def whoami(request):
return Response(json_body=dict(request.session))
views = {'anonymous_visit': anonymous_visit, 'login': login, 'logout': logout, 'whoami': whoami}
app = TestApp(make_app(views))
app.get('/anonymous_visit')
print('before login:', app.get('/whoami').json)
app.get('/login').follow()
print('after login: ', app.get('/whoami').json)
app.get('/logout').follow()
print('after logout:', app.get('/whoami').json)
Source: Pyramid: ISession.invalidate
Authentication in Pyramid 2.x is normally handled by a security policy, which may or may not use the session as storage
(SessionAuthenticationHelper in pyramid.authentication does). The pages on the security policy and on CSRF in this
section show how invalidate() fits into login and logout. The deprecated authentication policies of Pyramid 1.x are
legacy and are not used here.
Expiry: timeout, max_age and reissue_time
Three settings control how long a signed-cookie session lives, and they act in different places.
-
max_ageis the lifetime of the cookie in the browser.Nonemeans "until the browser is closed". -
timeoutis checked by the server on every request against the last-access time stored inside the cookie. If the gap is larger thantimeout, the data is dropped. Amax_ageshorter thantimeoutwins; a longer one adds nothing. -
reissue_timedecides how often the cookie, and with it the "last access" time, is rewritten when the session is only read. The default0rewrites on every access (a sliding window and aSet-Cookieon every response that touches the session); a value such as 60 rewrites at most once a minute;Nonerewrites only when the data changes, giving an absolute lifetime measured from the last change.
The rewriting has a consequence for caching: any response that carries Set-Cookie must not be shared by a cache, so a
public page that reads the session with the default reissue_time is effectively uncacheable. Either keep the session
out of cacheable views or set reissue_time to None or a larger value.
One subtlety: after a timeout the session is empty, but session.new stays False because the cookie itself was valid
(it only expired by age). Do not use new to detect "the visitor’s session has just been reset"; test for the
presence of the keys you need. The example drives the clock with unittest.mock to show all three cases without waiting.
from unittest import mock
import pyramid.session
def read_visits(request):
session = request.session
session['visits'] = session.get('visits', 0) + 1
return Response(json_body={'visits': session['visits'], 'new': session.new})
def read_only(request):
return Response(json_body={'visits': request.session.get('visits', 0)})
views = {'visits': read_visits, 'peek': read_only}
def at(clock, app, path):
with mock.patch.object(pyramid.session.time, 'time', return_value=clock):
return app.get(path)
start = 1_800_000_000
# 1. Idle for longer than the timeout: the data is discarded, but new stays False.
app = TestApp(make_app(views, timeout=600))
at(start, app, '/visits')
print('within timeout :', at(start + 300, app, '/visits').json)
print('after timeout :', at(start + 300 + 601, app, '/visits').json)
# 2. reissue_time=None: reading does not refresh the cookie, so the session ends
# `timeout` seconds after the last change even though the visitor kept reading.
app = TestApp(make_app(views, timeout=600, reissue_time=None))
at(start, app, '/visits')
at(start + 500, app, '/peek')
print('no read refresh :', at(start + 700, app, '/peek').json)
# 3. The default reissue_time=0: every read slides the window forward.
app = TestApp(make_app(views, timeout=600))
at(start, app, '/visits')
at(start + 500, app, '/peek')
print('sliding window :', at(start + 700, app, '/peek').json)
# 4. A read-only request with reissue_time=None sends no Set-Cookie header at all.
app = TestApp(make_app(views, reissue_time=None))
at(start, app, '/visits')
print('Set-Cookie on read:', 'Set-Cookie' in at(start + 5, app, '/peek').headers)
Source: Pyramid: pyramid.session
Flash Messages
A flash message is a short note that the application stores in the session during one request so that it can be
shown during a later one, once. The classic use is the post/redirect/get pattern: a form is submitted with POST,
the view changes data, adds a message such as "Shelf created" and answers with a redirect; the following GET page
pops the messages from the session and renders them, and a refresh of that page shows nothing. The second use is
generic code (a library, a tween, a subscriber) that wants to tell the user something without owning a template.
The API is three methods, all on the session:
| Method | Behaviour |
|---|---|
|
Append |
|
Remove the whole queue and return its messages as a list. A second call returns an empty list. |
|
Return the messages of the queue without removing them, so the same page can be rendered twice or a decorator can check whether there is anything to show. |
Queues are independent. A common convention is one queue per severity (success, info, error), which lets the
template group and style them, or one queue per place on the page (a global banner, a form-level message). The queue
names are stored in the session under keys that start with an underscore, so a queue is just a list inside the session
and counts against the 4 KB budget.
from pyramid.testing import DummyRequest
def flash_demo(request):
session = request.session
session.flash('Welcome back') # default queue
session.flash('Shelf created', 'success')
session.flash('Shelf created', 'success', allow_duplicate=False) # ignored: already queued
session.flash('Shelf created', 'success') # appended: duplicates are allowed by default
session.flash({'level': 'warning', 'text': 'Quota almost used'}, 'banner')
result = {
'peek success': session.peek_flash('success'),
'peek again': session.peek_flash('success'),
'pop success': session.pop_flash('success'),
'pop success again': session.pop_flash('success'),
'default queue': session.peek_flash(),
'banner': session.peek_flash('banner'),
}
return Response(json_body=result)
app = TestApp(make_app({'flash_demo': flash_demo}))
for key, value in app.get('/flash_demo').json.items():
print(f'{key:18}', value)
Source: Pyramid: Flash Messages
Both flash and pop_flash count as changes and peek_flash counts as a read, so a request that pops messages
writes the (now empty) queue back. Two pitfalls follow.
-
Messages are consumed by the first request that pops them. If the redirect target is not a page (an image, a JSON call, a favicon that hits the same route) or if two tabs race, the message goes to the wrong place or vanishes. Pop only in the view or template that actually renders messages, and do not pop in a global subscriber that runs for every request.
-
Output is not escaped by the session.
pop_flashreturns exactly what was stored, and a message may include user input (a shelf name). Render it through an auto-escaping template, never concatenate it into HTML.
The complete example below puts the pattern into the Shelves application: a form handler that flashes and redirects, and a list page that pops the queues and renders them with an auto-escaping Jinja2 environment (the same engine the cookiecutter project uses through pyramid_jinja2; see Templates for the integration and the macros that fit flash rendering).
import jinja2
from pyramid.httpexceptions import HTTPSeeOther
env = jinja2.Environment(autoescape=True)
PAGE = env.from_string(
'{% for level in levels %}'
'{% for text in messages[level] %}<p class="flash {{ level }}">{{ text }}</p>{% endfor %}'
'{% endfor %}'
'<ul>{% for shelf in shelves %}<li>{{ shelf }}</li>{% endfor %}</ul>'
)
SHELVES = ['Fiction']
def list_shelves(request):
levels = ('success', 'error')
messages = {level: request.session.pop_flash(level) for level in levels}
return Response(PAGE.render(levels=levels, messages=messages, shelves=SHELVES), content_type='text/html')
def create_shelf(request):
name = request.POST.get('name', '').strip()
if not name:
request.session.flash('A shelf needs a name', 'error')
else:
SHELVES.append(name)
request.session.flash(f'Shelf {name} created', 'success')
return HTTPSeeOther(location=request.route_url('list_shelves')) # always redirect after POST
config = Configurator(session_factory=SignedCookieSessionFactory(SESSION_SECRET, httponly=True))
config.add_route('list_shelves', '/shelves')
config.add_route('create_shelf', '/shelves/new')
config.add_view(list_shelves, route_name='list_shelves', request_method='GET')
config.add_view(create_shelf, route_name='create_shelf', request_method='POST')
app = TestApp(config.make_wsgi_app())
page = app.post('/shelves/new', {'name': 'Poetry'}).follow()
print('after create:', [p for p in page.html.find_all('p')][0].text)
print('refresh :', [p.text for p in app.get('/shelves').html.find_all('p')])
page = app.post('/shelves/new', {'name': ''}).follow()
print('after error :', [p.attrs['class'] + [p.text] for p in page.html.find_all('p')])
page = app.post('/shelves/new', {'name': '<script>alert(1)</script>'}).follow()
print('escaped :', '<script>' not in page.text, '<script>' in page.text)
pop_flash is called once per queue and the whole thing is one round trip of the cookie in each direction. In a real
page the shelf name would of course be validated and stored in a database (see the data pages of this section); the
point here is that the message is shown once, survives the redirect, is separated by queue and is escaped on output.
Form validation errors that belong to a field do not need flashing at all: re-render the form with the errors, as in
the forms page of this section.
When you need flash messages from an XHR or JSON API, return the message in the response body instead. Flash messages are a feature of the page flow, and a JSON client that does not read cookies would never see them.
Alternative Session Factories
The core factory is deliberately minimal. The official sessions chapter lists three add-ons, and the community has others. Maintenance matters for a security-relevant component, so the table gives the state of each project on PyPI as checked on 2026-10-07 rather than judging by name. "Last release" is the date of the latest file on PyPI.
| Package | Storage | Last release | Notes |
|---|---|---|---|
|
Encrypted cookie (PyNaCl |
1.0.0, 2020-08-17 (classified "Mature") |
Hides the content from the visitor, but it is still a client-side cookie with the same size limit and no revocation.
Its |
|
Redis, server side |
1.0.1, 2015-02-20 |
Listed in the official chapter but with no release for more than ten years, no declared Python version requirement and no Pyramid 2.x classifier. Treat it as unmaintained and verify it against Pyramid 2.1 yourself before depending on it. |
|
Redis, server side |
1.8.1, 2025-10-27 |
A separate project on PyPI whose own description calls it a "high performance and actively maintained" server-side session framework for Pyramid and Redis. It is not mentioned in the official chapter and was not tested for this page; read its changelog and test it against 2.1 before adopting. |
|
Beaker backends (files, memory, database, memcached and others) |
0.9, 2024-04-05 |
Listed in the official chapter. It is a thin integration of the Beaker library, so session behaviour and security depend on Beaker’s own maintenance and configuration; review both. |
The choice between the options:
-
Signed cookie (the default): no infrastructure, scales across workers because the state travels with the request. Limited to about 4 KB of non-sensitive JSON, and no server-side revocation.
-
Encrypted cookie (
pyramid_nacl_session): same operational model, content hidden. Keep the size limit and the lack of revocation in mind and pass a JSON serializer. -
Server-side store (Redis, a SQL table, Beaker): the cookie holds only a random id, so size and confidentiality are no longer issues and sessions can be revoked, at the price of a shared data store, an expiry strategy and one lookup per request. Use it for anything beyond preferences and flash messages, and for a login that must be revocable.
The first example shows pyramid_nacl_session with the JSON serializer, then reads the cookie as in the earlier
example: it is no longer readable. The package is optional and is not installed with Pyramid.
python -m pip install pyramid_nacl_session
from pyramid.session import JSONSerializer
from pyramid_nacl_session import EncryptedCookieSessionFactory
nacl_secret = secrets.token_bytes(32) # exactly 32 random bytes; share it between workers via config
factory = EncryptedCookieSessionFactory(nacl_secret, serializer=JSONSerializer(), httponly=True)
app = TestApp(make_app(session_factory=factory))
app.get('/preferences?sort=author')
cookie = app.cookies['session']
raw = base64.urlsafe_b64decode(cookie + '=' * (-len(cookie) % 4))
print('author' in raw.decode('latin-1'), len(raw) > 0) # False: the sort order is not visible
print(app.get('/preferences').json) # the application still reads it
Redis itself is covered in Redis. Whatever store you pick, the add-on documents its own
settings (usually a session.* prefix in the .ini file, read with the settings mechanism described in
Settings, Environment & .ini Files) and the application
code keeps using request.session.
Writing Your Own Server-Side Factory
If no add-on fits, you implement ISessionFactory and ISession. The official chapter points to the cookie
implementation in pyramid.session as inspiration. The example below is a complete server-side factory with a store
interface. The in-memory store is for tests and single-process demos; replace its three methods with Redis (SET with an
expiry) or a SQL table in production, and keep JSON as the value format so that sessions stay portable between stores.
Design choices worth copying:
-
The cookie contains only a random, unguessable id from
secrets.token_urlsafe; it isHttpOnly,SameSite=Lax, andSecurein production. -
Everything stored is serialized with
json.dumps, which keeps the JSON-only contract of Pyramid 2.0 and prevents storing live objects whose later mutation would not be persisted. -
The session is saved at the end of any request that touched it, which removes the
changed()gotcha for nested data at the price of a write per request; if that is too expensive, track changes instead. -
invalidate()deletes the server-side record and the next save issues a new id, which is what defeats session fixation.
import time
from pyramid.interfaces import ISession
from zope.interface import implementer
class MemorySessionStore:
"""Stand-in for Redis or SQL: get, set and delete JSON strings by id."""
def __init__(self):
self._data = {}
def get(self, sid):
return self._data.get(sid)
def set(self, sid, value):
self._data[sid] = value
def delete(self, sid):
self._data.pop(sid, None)
def ServerSideSessionFactory(store, cookie_name='sid', timeout=1200, secure=False):
@implementer(ISession)
class ServerSideSession(dict):
def __init__(self, request):
super().__init__()
self.request = request
self.sid = request.cookies.get(cookie_name)
self.new = True
self.created = int(time.time())
self._invalidated = False
record = store.get(self.sid) if self.sid else None
if record is not None:
saved = json.loads(record)
if time.time() - saved['accessed'] <= timeout:
self.update(saved['data'])
self.created = saved['created']
self.new = False
else:
store.delete(self.sid)
if self.new:
self.sid = None
request.add_response_callback(self._save) # runs only if the session was used
# ISession
def changed(self):
pass # everything is saved at the end of the request
def invalidate(self):
if self.sid:
store.delete(self.sid)
self.sid = None
self._invalidated = True
self.clear()
def flash(self, msg, queue='', allow_duplicate=True):
queue_list = self.setdefault('_f_' + queue, [])
if allow_duplicate or msg not in queue_list:
queue_list.append(msg)
def pop_flash(self, queue=''):
return self.pop('_f_' + queue, [])
def peek_flash(self, queue=''):
return self.get('_f_' + queue, [])
def new_csrf_token(self):
token = secrets.token_hex(20)
self['_csrft_'] = token
return token
def get_csrf_token(self):
return self.get('_csrft_') or self.new_csrf_token()
# persistence
def _save(self, request, response):
if not self and self.sid is None:
if self._invalidated:
response.delete_cookie(cookie_name)
return # nothing to store, no cookie
if self.sid is None:
self.sid = secrets.token_urlsafe(32)
record = {'accessed': time.time(), 'created': self.created, 'data': dict(self)}
store.set(self.sid, json.dumps(record))
response.set_cookie(cookie_name, self.sid, httponly=True, samesite='Lax', secure=secure)
return ServerSideSession
store = MemorySessionStore()
app = TestApp(make_app(views={'visits': read_visits, 'peek': read_only, 'login': login, 'whoami': whoami},
session_factory=ServerSideSessionFactory(store)))
print(app.get('/visits').json, app.get('/visits').json)
sid = app.cookies['sid']
print('cookie holds only an id:', len(sid), 'characters; server record:', json.loads(store.get(sid))['data'])
app.get('/login').follow()
print('id rotated on login:', app.cookies['sid'] != sid, '| old id still stored:', store.get(sid) is not None)
# Revocation: delete the record on the server and the cookie is worthless.
store.delete(app.cookies['sid'])
print('after revocation:', app.get('/whoami').json)
Two limits of the sketch are deliberate: it never purges abandoned records (give the real store a time-to-live) and it
trusts the id alone (rotate on privilege changes and keep ids long and random). The timeout here is a sliding window
only if you also refresh the store entry’s expiry on every save, which _save does.
Testing Sessions
Functional tests use webtest.TestApp, which keeps a cookie jar exactly like a browser, so a sequence of requests shares
one session, as the examples above relied on. app.reset() clears the jar to simulate a new visitor and
app.set_cookie(name, value) plants one. Unit tests of a view that only reads or writes the session do not need an
application at all: pyramid.testing.DummyRequest has a session that is a DummySession, a plain dictionary that
implements the flash and CSRF methods in memory (without signing, serialization or timeouts).
from pyramid.testing import DummyRequest
def create_shelf_unit(request):
request.session.flash('Shelf created', 'success')
return {'name': request.POST['name']}
request = DummyRequest(post={'name': 'Poetry'})
print(create_shelf_unit(request), request.session.peek_flash('success'))
print(type(request.session).__name__)
# Functional test of the cookie jar: reset() makes a new visitor.
app = TestApp(make_app())
app.get('/preferences?sort=author')
print(app.get('/preferences').json['visits'])
app.reset()
print(app.get('/preferences').json['visits'])
Source: Pyramid: pyramid.testing
A unit test with DummySession cannot catch the JSON-only rule, the size limit or the changed() gotcha, because the
dummy session never serializes. Keep at least one functional test per feature that goes through the real factory.
The testing page later in this section covers fixtures and the other test helpers.
Pitfalls and Security Checklist
-
The default cookie is signed, not encrypted: never store secrets in it, and use
secure=True(TLS) andhttponly=Trueoutside local development. -
Generate the secret with
secrets.token_hex(32)(or longer), keep it in the environment or a secret store, share one value between all workers, and never commit it. Rotating it logs every visitor out. -
Keep the session small, flat and JSON-only. Store identifiers, not objects, and watch the 4 KB ceiling when flash queues grow.
-
Call
session.changed()after mutating a stored list or dictionary in place, or replace the value instead. -
The session cookie is a bearer credential. Combine it with CSRF protection for state-changing requests and keep
samesiteatLaxor stricter. -
Escape flash messages on output; they may contain user input.
-
Pop flash queues only where they are rendered, and always redirect after a successful
POST. -
Reading the session with
reissue_time=0sets a cookie on every response and makes pages uncacheable; choosereissue_timedeliberately. -
After a timeout
session.newis stillFalse; test for the keys you need instead. -
Use a server-side factory when you need revocation, bigger data or confidential data, and verify the maintenance state of the add-on first.
-
Authentication state and permissions belong to the security policy of the application, not to ad-hoc keys in the session. For general guidance on session management, see the OWASP Session Management Cheat Sheet and OWASP Cheat Sheet Series pages listed below; this site has no OWASP pages of its own.
What Changed Recently and Where Older Material Is Outdated
| Version | Change | Effect on your code |
|---|---|---|
Pyramid 1.10 |
|
Cookies are no longer sent on cross-site subrequests by default; set |
Pyramid 2.0 |
|
Session values must be JSON-serializable. Old pickled cookies are dropped. Tutorials and answers that store arbitrary objects in the session are outdated. |
Pyramid 1.9 |
|
Import CSRF helpers from |
Pyramid 2.1 |
No change to the session API; Python 3.6 to 3.9 support dropped, 3.12 to 3.14 added |
Everything on this page applies to 2.0 and 2.1. |
|
Default serializer is still pickle |
Pass |
Older Pyramid material (including the Pyramid 1.x tutorials) often shows UnencryptedCookieSessionFactoryConfig, an
earlier factory that no longer exists in Pyramid 2.1, and explanations that sessions can hold any picklable object.
Both are obsolete: use SignedCookieSessionFactory with JSON data. The 1.x authentication and authorization policies are
legacy and are replaced by the security policy of Pyramid 2.x, which the security pages of this section describe.
Summary
-
A session is the per-visitor namespace behind
request.session. Pyramid defines theISessioninterface and the lazy attribute, and registers the implementation withConfigurator(session_factory=...)orset_session_factory. -
SignedCookieSessionFactory(secret, ...)keeps the session in a signed, readable, JSON-only cookie of at most about 4 KB. Setsecure=True,httponly=True, a sensiblesamesite, atimeout, and areissue_timethat matches your caching and expiry needs. -
Call
changed()after in-place mutations,invalidate()on login and logout, and keep the secret long, random and out of the repository. -
Flash messages are queues of one-shot messages in the session:
flash(msg, queue, allow_duplicate),pop_flashandpeek_flash, used with post/redirect/get and escaped on output. -
For revocation, large data or confidential data, use a server-side factory: an add-on after checking its maintenance, or your own
ISessionimplementation as shown above. -
Test with
webtest.TestApp(real cookie jar) and withDummyRequest().session(unit level).