# Python

> **Draft Standard**
>
> The information found on this page is a **work in progress**. As feedback is collected, there will likely be revisions.
>
> Nothing in the draft should be taken as a requirement or directive at this point in time.

Python is used at Northwestern for web applications, libraries, data integrations, and console tooling. This document covers the language and the tooling shared across all of them.

Python has no single default web framework. Where the guidance below depends on one, it is written in terms of Django, Flask and FastAPI, the frameworks in widest use among the teams contributing to this document. The underlying principles apply to any framework. See [Web Applications](https://sdcc.entapp.northwestern.edu/coding-standards/python/#web-applications) for choosing one, and `CONTRIBUTING.md` in the project repository if your team’s framework deserves coverage here.

These record what this draft has not settled yet. They are addressed to the SDCC review committee rather than to readers of the standard. Check the box before printing to take them with you.

> **PY-00 · For SDCC Review - Which of these belong in the General Standards?**
>
> Several requirements in this document are not Python-specific. They are written here because this document was written first, not because they belong to this stack. Where another stack document already states the same thing in its own words, the requirement is maintained in two places and can drift in either.
>
> Each is asked again where it applies, with the detail needed to answer it:
>
> | Question                                                                     | Subject                              | The General Coding Standards today                                    |
> | ---------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------- |
> | [PY-06](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-06) | Local HTTPS hostname and certificate | Require a repository to run on one laptop; silent on signing in to it |
> | [PY-09](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-09) | Impersonation                        | Silent; the Laravel document states the same three requirements       |
> | [PY-10](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-10) | Non-Northwestern users               | A section that admits it has no recommendation                        |
> | [PY-11](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-11) | Authorization                        | No Authorization section at all                                       |
> | [PY-13](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-13) | Manual accessibility testing         | Silent on cadence and coverage                                        |
> | [PY-17](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-17) | `SameSite`                           | `secure`, `httpOnly`, and expiry, but not `SameSite`                  |
>
> Two further questions turn partly on the same split: [PY-14](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-14) asks whether a cookie requirement stronger than the general standards’ is this document’s to set, and [PY-16](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-16) whether cookie management belongs here at all.
>
> These are worth deciding together. Answered one at a time, the boundary between this document and the general standards becomes a record of which question was raised on which day, and the next stack document inherits it.
>
> Is there a principle that settles the set?

## Code Management

GitHub SHOULD be used for code storage, and GitHub Actions SHOULD be used for CI.

### Code Review

The checks listed under [Automated Tests](https://sdcc.entapp.northwestern.edu/coding-standards/python/#automated-tests) SHOULD be automatically run for pull requests, and SHOULD be required to pass before merging is allowed.

Where an application supports more than one Python version, the test suite SHOULD be run against each supported version.

Formatting and lint fixes SHOULD be applied automatically and committed, so that review focuses on behavior rather than style.

These checks MAY additionally be run in a pre-commit hook for faster local feedback. Local hooks are a convenience rather than a control: they can be bypassed with `git commit --no-verify`, and they are not installed automatically when a repository is cloned. The checks that gate merging SHOULD therefore run in CI.

### Tracked Files

Repositories SHOULD use a Python `.gitignore` covering bytecode, build artifacts, and tool caches; GitHub’s template is a reasonable starting point. The virtual environment directory, any real `.env` file, and any locally-generated certificate or private key MUST NOT be committed.

## Local Development

In line with the General Coding Standards on [Local Development](https://sdcc.entapp.northwestern.edu/coding-standards/general/#local-development), a repository is expected to run entirely on one developer’s laptop, without reaching for shared infrastructure such as a common database or cloud storage.

A repository set up this way generally provides:

* a pinned interpreter version
* a virtual environment, with dependencies installed from a committed lockfile
* any dependent services, runnable locally
* documented configuration
* a command that loads usable sample data

A repository that meets these standards will typically contain:

**uv**

* .python-version exact interpreter version
* pyproject.toml PEP 621 metadata, requires-python
* .env.example documented; may hold op\:// references
* uv.lock committed; identical resolution for everyone

**pip-tools**

* .python-version exact interpreter version
* pyproject.toml PEP 621 metadata, requires-python
* .env.example documented; may hold op\:// references
* requirements.in direct dependencies, hand-edited
* requirements.txt compiled by pip-compile; hash-pinned

> **PY-01 · For SDCC Review - Include Conda?**
>
> Is Conda used often enough to be worth a third tab? Would go here, and anywhere else we cover dependency management.
>
> If so, it may want its own entry in the “`pip` to `uv` migration” notes as well.

### Python Interpreter

Projects SHOULD declare `requires-python` in the `[project]` table of `pyproject.toml`, and SHOULD commit a `.python-version` file. The two are complementary:

| Mechanism         | Declares                                       | Read by                                                       |
| ----------------- | ---------------------------------------------- | ------------------------------------------------------------- |
| `requires-python` | the range of interpreters the project supports | packaging tools and dependency resolvers                      |
| `.python-version` | the single exact version to use                | version managers (`uv`, `pyenv`) when creating an environment |

Developers SHOULD work inside a virtual environment rather than installing packages into the system interpreter, and dependencies SHOULD be installed from a committed lockfile so that every developer and the CI pipeline resolve identical versions.

See [Dependency Management](https://sdcc.entapp.northwestern.edu/coding-standards/python/#dependency-management) for the tooling that creates the virtual environment and resolves the lockfile.

**.python-version**

```text
3.12.7
```

**pyproject.toml**

```toml
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.12"


...
```

> **Why?**
>
> Python projects frequently target a different interpreter version than the one installed on a developer’s machine, and globally-installed packages leak between projects. Without isolation, “works on my machine” problems are hard to reproduce and hard to distinguish from genuine bugs.

### Environment Variables

A sample `.env` file (such as `.env.example`) SHOULD be included in the repository, and it SHOULD document what needs to be filled in to get a working environment. It MAY contain [1Password vault reference URLs](https://developer.1password.com/docs/cli/secrets-config-files), enabling the `op` utility to fill it out for a new team member.

**.env.example**

```dotenv
# Copy this file to `.env`, then fill in the blanks.
#
# Values written as op:// references can be filled in for you:
#
#     op inject --in-file .env.example --out-file .env
#
# `op inject` resolves the op:// references and leaves everything else alone,
# so it is safe to re-run.
#
# What it cannot resolve is what needs a human.
# Each of those is marked with what to do.
#
# This file is committed, so nothing in it may be a real secret.




# ==============================================================================
#   Application
# ==============================================================================


# Shared, non-secret values may be included as-is.
APP_ENV=local
DEBUG=true


# Generate your own.
# Per-developer values mean a leaked key compromises nothing beyond that laptop:
#
#     python -c "import secrets; print(secrets.token_urlsafe(50))"
SECRET_KEY=


# Optional.
# The app falls back to a default if unset, so this can be left alone until you
# have a reason to change it.
LOG_LEVEL=




# ==============================================================================
#   Services
# ==============================================================================


# Use as-is.
# This matches the service in `compose.yaml`, so it works once you run:
#
#     docker compose up -d
#
# If the port is already taken on your machine, change it in BOTH files.
DATABASE_URL=postgresql://app:app@localhost:5432/app_dev


# Leave blank locally.
# This key should not be supplied on a local machine:
#
#   - If set, the application sends real emails to real people.
#   - If unset, it writes messages to the local catcher instead.
#
# Anything that can reach real people should not have a working default
# in a committed file.
SMTP_API_KEY=




# ==============================================================================
#   Entra ID
# ==============================================================================


# Filled in by `op inject`.
# Request access to the vault from the team lead; no need to type these by hand.
OIDC_TENANT_ID=op://Development/myapp-entra/tenant-id
OIDC_CLIENT_ID=op://Development/myapp-entra/client-id


# Obtain your own.
# The team vault holds shared credentials; this is issued per person.
GRAPH_API_TOKEN=
```

### Dependent Services

Services an application depends on – databases, caches, message brokers, object storage – SHOULD be runnable on a developer’s machine, and the repository SHOULD document how to start them. Local development SHOULD NOT require access to a shared database or other environment-specific infrastructure.

> **Already built? — Improve as you go.**
>
> A project currently pointed at a shared development database SHOULD move to local services, but MAY do so one service at a time rather than all at once. Each dependency that becomes runnable locally is an improvement on its own.

The mechanism is left to the project. A container definition such as `compose.yaml` is the most common approach; a documented native installation is reasonable for a single simple dependency.

Where a managed service has no local equivalent, projects SHOULD substitute a stand-in rather than connecting to the real one: MinIO in place of S3-compatible storage, for example, or a local SMTP catcher such as Mailpit in place of an email provider. Review the guidance in the [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#local-development) regarding stand-in services and mocks.

**compose.yaml**

```yaml
# Starts the services the application expects locally:
#
#     docker compose up -d
#
# Only the application's dependencies belong here.
# How the application itself is packaged is a deployment concern,
# and running it directly on the host keeps the edit-and-reload loop fast.
#
# Readiness checks are omitted; they are covered under Automated Tests,
# where a CI run has to wait on them.


services:


  # One service per dependency.
  db:


    # Pinned to a major version, matched to what the deployed environments run.
    # An upgrade then arrives as a reviewable change, not a surprise.
    image: postgres:16


    # Weak credentials are fine:
    # this database is disposable, local, and unreachable from another machine.
    # They have to agree with `DATABASE_URL` in `.env.example`,
    # or a new team member gets a database that exists but rejects them.
    environment:
      POSTGRES_USER:     app
      POSTGRES_PASSWORD: app
      POSTGRES_DB:       app_dev


    # Change the left-hand number if 5432 is taken; DATABASE_URL must match.
    ports:
      - "5432:5432"


    volumes:
      - pgdata:/var/lib/postgresql/data


  # A stand-in for a managed service, rather than a connection to the real one.
  # `SMTP_API_KEY` is blank in `.env.example`, so mail arrives here instead.
  mail:


    # Pinned for the same reason as the database above.
    image: axllent/mailpit:v1.21


    # 1025 receives mail; 8025 serves the web interface that reads it.
    ports:
      - "1025:1025"
      - "8025:8025"




# A named volume, so the database survives `docker compose down`.
# `docker compose down -v` starts over from empty.
volumes:
  pgdata:
```

> **Why?**
>
> A shared development database couples every developer’s work together: a schema change lands for everyone at once, and switching branches can leave the data in a state that matches nobody’s code.
>
> Pinning service versions alongside the application also means a Postgres upgrade arrives as a reviewable change rather than a surprise.

### Sample Data

Repositories SHOULD provide a documented command that loads demo data, and it SHOULD provide enough data to use the application normally. This command SHOULD be documented in the `README.md` file.

The mechanism varies by framework. For example:

* A Django project MAY use fixtures or a management command
* A Flask project MAY register a custom `flask` CLI command
* Other projects MAY provide a script, or a `make` target wrapping such a script

Review the guidance in the [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#local-development) document regarding crafted sample data.

> **PY-02 · For SDCC Review - Which sample data libraries?**
>
> Proposing `factory_boy` for building model instances, with [Faker](https://faker.readthedocs.io/) supplying the field values – the closest analogue to Laravel’s model factories plus Faker.
>
> Django projects may prefer fixtures for static reference data. Objections, or a better pairing?

## Automated Tests

The following tools MAY be used for automated tests. All of them apply to web applications.

| Type              | Tool                                                                              | Notes                                                                                                                                                                                                                             |
| ----------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| End-to-End        | [Playwright](https://playwright.dev/python/)                                      | `pytest-playwright` runs browser tests under the same runner; pair with [`pytest-django`](https://pytest-django.readthedocs.io/)’s `live_server` fixture                                                                          |
| Integration       | [pytest](https://docs.pytest.org/)                                                | Framework plugins supply the fixtures: [`pytest-django`](https://pytest-django.readthedocs.io/), [`pytest-flask`](https://pytest-flask.readthedocs.io/), FastAPI’s [`TestClient`](https://fastapi.tiangolo.com/tutorial/testing/) |
| Unit              | [pytest](https://docs.pytest.org/)                                                |                                                                                                                                                                                                                                   |
| Static Analysis   | [mypy](https://mypy-lang.org/) or [pyright](https://microsoft.github.io/pyright/) | Depends on the annotations described [below](https://sdcc.entapp.northwestern.edu/coding-standards/python/#type-annotations-and-static-analysis)                                                                                  |
| Lint & Formatting | [Ruff](https://docs.astral.sh/ruff/)                                              | `ruff format` and `ruff check`; replaces Black, Flake8, isort, and pyupgrade                                                                                                                                                      |
| Coverage          | [`pytest-cov`](https://pytest-cov.readthedocs.io/)                                | Reports on the suite above rather than adding to it                                                                                                                                                                               |

These tools SHOULD be configured in `pyproject.toml` rather than in a file apiece. Ruff, pytest, and mypy each accept configuration of their own, but keeping the settings together means one file describes the project, and a developer cannot end up on different settings than CI. The sample below is one such file, shown a section at a time.

**Project**

```toml
# General configuration
[project]
name = "myapp"
version = "0.1.0"


# The range of interpreters the project supports.
requires-python = ">=3.12"


# Tooling the application itself does not need at runtime.
# A PEP 735 group is not installed for anyone consuming the package,
# and unlike an extra it never becomes part of the published metadata.
[dependency-groups]
dev = ["mypy", "pytest", "pytest-cov", "pytest-playwright", "ruff"]
```

**Test Suites**

```toml
[tool.pytest.ini_options]


# Where collection starts. Naming it keeps startup fast,
# and stops stray test-shaped files elsewhere in the tree from being collected.
testpaths = ["tests"]


# Required by a src/ layout.
# The package lives in src/myapp rather than ./myapp,
# so it cannot be imported from the repository root until it is installed.
# Without this line, every test fails on its import statement.
pythonpath = ["src"]


# Promote two easy-to-miss warnings into errors:
#
#   - An unregistered marker, usually a typo. The marker then does nothing,
#     and a `-m` selection silently misses the test it was meant to tag.
#   - An unrecognized key in this table, also usually a typo.
#     The setting is then read as nothing at all.
addopts = "--strict-markers --strict-config"


# Framework plugins are wired here too, e.g.
# DJANGO_SETTINGS_MODULE = "myapp.settings.test"
```

**Static Analysis**

```toml
# This file assumes mypy; a project on pyright configures the same things under
# [tool.pyright]. Both, and the reasoning for each setting, are described under
# Type Annotations and Static Analysis.


[tool.mypy]
python_version = "3.12"
strict = true


# The exclusion list described under Type Annotations and Static Analysis.
[[tool.mypy.overrides]]
module = ["myapp.legacy.*"]
ignore_errors = true
```

**Lint & Formatting**

```toml
[tool.ruff]
line-length = 100


# Marks which code is first-party, so that import sorting can tell it from
# third-party packages and group the two separately.
src = ["src", "tests"]


[tool.ruff.lint]


# Ruff enables only a small default set, so rules are opted into explicitly.
# These cover pycodestyle, Pyflakes, isort, pyupgrade, bugbear, and bandit.
#
# `G` and `LOG` cover the logging calls described under Logging:
# they reject a message built with an f-string, which leaves a log platform
# unable to group the events, and catch the deprecated and misused parts of
# the logging API.
select = ["E", "F", "I", "UP", "B", "S", "G", "LOG"]


[tool.ruff.lint.per-file-ignores]


# bandit flags `assert` because `python -O` strips assertions from a program,
# which silently removes any check written as one.
# In a test suite that is the entire idiom, so the rule is dropped there.
"tests/**" = ["S101"]
```

**Coverage**

```toml
[tool.coverage.run]


# Count the paths taken through a condition, not only the lines that ran.
# An `if` with no `else` counts as fully covered by line count
# even when the condition was never false;
# branch coverage records the path that was never taken.
branch = true


# Measure the whole package, not only the modules a test happened to import.
# Without this, a module nothing imports is left out of the report altogether,
# and the percentage reads higher than the suite deserves.
source = ["src"]
```

### Test Suites

Playwright drives a browser, so it applies only where there is one. The end-to-end layer of a console application or a data integration exercises the entry point instead – the command, or the job – and pytest is the tool for that.

The standard library’s `unittest` requires no dependency, but pytest is the better default: fixtures compose, parameterization is declarative, and pytest collects and runs `unittest.TestCase` classes unchanged.

> **Already built? — No problem.**
>
> Adopting pytest is not a migration. Existing `unittest` tests keep running as they are, and new tests SHOULD be written in pytest’s style.

An integration or end-to-end suite needs the services described under [Dependent Services](https://sdcc.entapp.northwestern.edu/coding-standards/python/#dependent-services) to be running before it starts. In CI, those services are created alongside the suite, so a run can reach one before it is ready. The result will surface as a failing test rather than as an infrastructure problem.

For this reason, services SHOULD declare readiness, and the pipeline SHOULD wait on it rather than sleeping for a fixed interval:

**compose.yaml**

```yaml
# The readiness block alone.
# The rest of the `db` service is under Dependent Services.


services:
  db:


    # Postgres opens its port a moment before it will answer queries,
    # so a suite starting alongside it can connect and then fail immediately.
    healthcheck:


      # `-h localhost` forces a TCP check: the Unix socket answers during
      # first-boot initialisation, before the port is listening.
      test: ["CMD-SHELL", "pg_isready -h localhost -U app -d app_dev"]
      interval: 5s
      retries: 5
      start_period: 10s
```

`docker compose up --wait` then blocks until every service declaring a healthcheck reports healthy, so the suite starts against a database that will answer. Failures during `start_period` do not count, so a slow first boot is not mistaken for a broken service – but a broken one still fails the run.

### Type Annotations and Static Analysis

In line with the General Coding Standards guidelines on dynamically-typed languages, Python applications SHOULD use type annotations to enable static analysis. See [Code Review](https://sdcc.entapp.northwestern.edu/coding-standards/python/#code-review) for where these checks are run.

> **Already built? — Improve as you go.**
>
> An existing project does not need to annotate its whole codebase to comply. Annotations SHOULD be added incrementally rather than in a single pass, and modules SHOULD be annotated as they are created or substantially modified.
>
> The checker SHOULD be made stricter as annotation coverage grows, so that the bar tracks the codebase rather than staying pinned to whatever its least-annotated module needed.

Two type checkers are in common use, and a repository SHOULD select one:

1. **mypy** is the reference implementation, developed alongside the typing PEPs.
2. **pyright** is faster, and infers more from unannotated code. It is the engine behind VS Code’s Pylance, so the editor and CI report the same errors. It is distributed as a Node package, which is worth weighing for a CI image that otherwise needs no Node.

Whichever checker is chosen, the following SHOULD be recorded in `pyproject.toml` rather than left implicit, so that an editor and CI apply the same bar:

* The **strictness level**: Which diagnostics the checker reports, and how much unannotated code it accepts without complaint.

* The **exclusions**: Modules whose errors are suppressed, so that the rest of the codebase can be checked at full strength. Un-annotated modules SHOULD be excluded by configuration rather than by relaxing the type checker globally. The exclusion list then serves as a record of the remaining work: entries SHOULD be removed over time, and SHOULD NOT be added for new code.

* The **target version**: The Python version the checker analyzes the code as. Left unset, it follows whichever interpreter happens to run the tool, so code that breaks on the oldest supported version can still pass. SHOULD be the lowest version in `requires-python` – see [Python Interpreter](https://sdcc.entapp.northwestern.edu/coding-standards/python/#python-interpreter).

**mypy**

Strictness lives in `[tool.mypy]`, and `strict = true` SHOULD be stated explicitly. Left unset, mypy does not look inside a function that carries no annotations – which in a partly-annotated codebase is most of them – so an unconfigured run reports very little and reads as a pass.

Exclusions are `[[tool.mypy.overrides]]` blocks, keyed by module pattern. The module is still analyzed; its errors are suppressed.

```toml
[tool.mypy]
python_version = "3.12"
strict = true


# Suppressed, not unchecked.
# Entries come off as modules are annotated; none are added for new code.
[[tool.mypy.overrides]]
module = ["myapp.legacy.*"]
ignore_errors = true
```

**pyright**

Strictness lives in `[tool.pyright]`, and `typeCheckingMode` SHOULD be stated explicitly. It defaults to `standard` rather than `strict`, so a project that says nothing is checked at a bar it did not choose.

Exclusions are the `ignore` list, keyed by path rather than by module. Those files are still analyzed; their diagnostics are suppressed. `exclude` is a different setting and the wrong one here: it drops files from analysis altogether, which also changes what is inferred about them wherever they are imported.

pyright can invert the arrangement – a lenient default with a `strict` list of paths opted in. This standard does not use it. A list that grows is a record of the work done; the exclusion list is meant to be a record of the work remaining, and to shrink.

```toml
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "strict"


# Suppressed, not unchecked.
# Entries come off as modules are annotated; none are added for new code.
ignore = ["src/myapp/legacy"]
```

> **Why?**
>
> Nothing in the Python interpreter checks type annotations; rather, a type signature is a claim about the code that a reviewer and a checker can act on without needing to run it. Static analysis works from that claim, and a Python checker infers very little without one. Annotations are therefore a precondition for the static analysis required by the General Coding Standards.

## Web Applications

This section applies to applications reached through a browser, and to HTTP APIs serving other clients. It does not apply to libraries, data integrations, or console applications.

New applications SHOULD use one of the following frameworks:

| Framework                                   | Supplies                                                                                                                | Choose when                                                                                                           |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| [Django](https://www.djangoproject.com/)    | ORM, migrations, authentication, an admin interface, templating, forms                                                  | The application is database-backed, renders its own pages, and needs an administrative surface                        |
| [Flask](https://flask.palletsprojects.com/) | Routing and request handling; persistence and authentication are assembled from packages such as SQLAlchemy and Alembic | The application is small, or is a thin layer over systems that already hold the data                                  |
| [FastAPI](https://fastapi.tiangolo.com/)    | Typed request and response models, with generated OpenAPI documentation                                                 | The client is a separate single-page application or another service, rather than pages the application renders itself |

> **Prefer something else?**
>
> Where a new application chooses a framework not listed above, the rationale SHOULD be recorded in its technical documentation.

> **PY-03 · For SDCC Review - Should the framework list be a recommendation?**
>
> Are we comfortable recommending these specific frameworks with SHOULD, or would it be better to soften to MAY?
>
> Will likely also impact the callout below the table, regarding what to do when an app chooses a different framework.

FastAPI is the one option where the annotations required under [Type Annotations and Static Analysis](https://sdcc.entapp.northwestern.edu/coding-standards/python/#type-annotations-and-static-analysis) do work at runtime rather than only in the checker.

Streamlit and similar data-app tools are a different kind of thing: they generate their own interface, and the application does not control the resulting markup. They MAY be used for internal dashboards and prototypes. They SHOULD NOT be used for applications offered to students, faculty, or staff — a team cannot meet the WCAG 2.1 AA requirement without control of the markup. Review the guidance in the [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#accessibility) regarding accessibility standards.

> **Already built? — No problem.**
>
> A project already working well on another framework has no obligation to move. The rest of this section is written to apply regardless of framework.

### Authentication

In line with the General Coding Standards on [Authentication](https://sdcc.entapp.northwestern.edu/coding-standards/general/#authentication), applications serving Northwestern students, faculty, and staff are expected to authenticate through SSO with Duo MFA, and custom web applications are expected to use Azure Entra ID OpenID Connect.

For a Python application, the practical argument is library support. Each of the frameworks discussed in this document has a maintained OpenID Connect integration, whereas the PingAM paths currently have no Python WebSSO package – an application taking one of those routes builds and maintains that layer itself.

> **Already built? — No problem.**
>
> An application already authenticating through PingAM or Fed/Shibboleth has no obligation to move to Entra ID. The general standards recommend OpenID Connect for new custom applications rather than a migration of existing ones.

An application on the PingAM path reads single sign-on state from the `nusso` cookie, which PingAM sets on the shared `.northwestern.edu` domain rather than on the application’s own. See [Cookies From Other Applications](https://sdcc.entapp.northwestern.edu/coding-standards/python/#cookies-from-other-applications) for reading a cookie the application did not set.

Applications MUST NOT collect Northwestern credentials themselves. A NetID and password are entered on the University’s own login page and nowhere else: an application MUST NOT present its own NetID password field, and MUST NOT accept or forward those credentials on a user’s behalf.

> **Why?**
>
> An application that collects NetID credentials teaches people to type them into whatever page asks, which is the behavior every phishing campaign depends on. It also puts MFA out of reach – the second factor is verified by the identity provider during the redirect, so an application holding the password has no way to complete it.

#### OpenID Connect

Most applications run the OpenID Connect redirect flow: the user is sent to the identity provider and returns with a code, which the application exchanges for tokens before establishing a session. Applications SHOULD use a maintained OpenID Connect library for this rather than implementing the flow directly. The redirect, the token exchange, and the `state` and `nonce` checks are each a place where a subtle mistake is also a security bug.

An application serving a separate front end or another service authenticates differently. There is no redirect flow to run: the client obtains a token and presents it, and the application’s job is to check it. Such applications MUST verify a token’s signature against the tenant’s published signing keys, and MUST check its issuer, audience, and expiry before trusting any claim it carries.

> **PY-04 · For SDCC Review - Examples?**
>
> This section is pending framework-specific examples and library recommendations for the auth flow – they can live here once authored.

> **PY-05 · For SDCC Review - What should logging out do?**
>
> Behind SSO, “log out” has two meanings and an application has to pick one:
>
> * Ending only its own session leaves the identity provider’s session intact, so the next sign-in succeeds with no prompt. On a shared machine in a lab or a library, this is a real exposure.
> * Ending the identity provider’s session as well signs the user out of every Northwestern application at once, which is usually more than they asked for.
>
> Two things need settling: which of these an application does by default, and how the interface tells the user which one just happened. A button reading “Log out” does not distinguish them, and a user who believes they have signed out of everything is worse off than one who knows they have not.

#### Signing In Locally

Signing in is the one part of a Python web application that cannot run entirely on a laptop: the identity provider is shared infrastructure, where [Local Development](https://sdcc.entapp.northwestern.edu/coding-standards/python/#local-development) otherwise expects a repository to stand alone.

> **PY-08 · For SDCC Review - Is a development login acceptable?**
>
> The guidance above assumes local development authenticates against the real tenant. The alternative is a development-only login path – a seeded user behind a `DEBUG` gate – which is roughly what Laravel projects get from an impersonation package. Each has a cost: the first makes a new developer wait on an application registration, the second means the path exercised locally is not the one that runs in production.
>
> The same answer governs how tests sign in. A browser-driven end-to-end test cannot complete an MFA prompt, so it needs either the same bypass or a way to establish a session directly, such as Django’s `force_login`. Whatever is decided here also belongs under [Automated Tests](https://sdcc.entapp.northwestern.edu/coding-standards/python/#automated-tests), which currently says nothing about authenticating at all.
>
> Which does this standard endorse, and if a bypass is allowed, what has to be true of it?

An Entra ID application registration accepts a redirect URI only if that URI was registered in advance, and the scheme has to be HTTPS everywhere except loopback. `http://127.0.0.1:8000/callback` is therefore registrable, though the portal will not add an HTTP loopback URI through its redirect URI field – it has to go in the application manifest.

Loopback is not necessarily enough, though. Where sign-in state travels in a cookie scoped to `.northwestern.edu`, the application has to be reached on a Northwestern hostname for that cookie to be sent at all, and no loopback address qualifies. What follows covers that case.

> **PY-07 · For SDCC Review - Which SSO method drives the hostname requirement?**
>
> The paragraph above (and the ensuing discussion) assume that sign-in state travels in a `.northwestern.edu`-scoped cookie, which is what forces local development onto a Northwestern hostname. That holds on the PingAM path, where the `nusso` cookie is read by the application itself.
>
> It is less clear for Entra ID OpenID Connect, which is what these standards recommend. There the identity provider holds the session, the application only receives a redirect, and Entra permits a loopback redirect URI – so the hostname may be a constraint of the SSO method rather than of OpenID Connect.
>
> Someone who knows the plumbing needs to confirm which applies to a new Entra-based application. The answer decides whether this subsection is universal or specific to projects on the Ping path.

Local development then needs three things beyond a virtual environment:

1. A **hostname** that resolves to the developer’s own machine.
2. A **certificate** for that hostname.
3. A **development server** that serves HTTPS using that certificate.

The hostname is usually added to the developer’s `hosts` file, and has to be the host of a redirect URI registered on the Entra ID application registration. The repository SHOULD document all three, and SHOULD provide a command that generates the local certificate.

The certificate SHOULD be issued by a locally-trusted authority such as one created by [mkcert](https://github.com/FiloSottile/mkcert), rather than self-signed. A self-signed certificate raises a browser warning, and where HSTS is set on the parent domain the browser will not offer a click-through at all.

> **PY-06 · For SDCC Review - Local HTTPS setup: Python or General?**
>
> The hostname and certificate requirements above name no Python API: any application running OpenID Connect locally needs a resolvable hostname and a locally-trusted certificate. The [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#local-development) require a repository to run on one laptop but say nothing about signing in to it.
>
> The `certifi` material below – HTTP clients that ignore the system trust store – is genuinely Python’s and would stay here.
>
> See also [PY-00](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-00).

An example is given below: the `hosts` entries, and a script that issues the certificate and concatenates the bundle. The requirement is that the repository provide a command; its form – a shell script, a `Makefile` target, a task runner – is the project’s choice.

**/etc/hosts**

```properties
# Local development for App Name
#
# Resolves the sign-in hostname to this machine.
# This name must be the host of a redirect URI registered on the Entra ID app.
#
# Ideally, the names used here should not shadow any real `.northwestern.edu`
# sites that exist in the public DNS.
#
# Append these entries -- the file's existing contents stay as they are.
#
# Both families are listed so that nothing falls through to public DNS,
# whichever one the client asks for first.
127.0.0.1  local.app-url.northwestern.edu
::1        local.app-url.northwestern.edu
```

**scripts/certs.sh**

```bash
#!/usr/bin/env bash
# Local HTTPS for the OIDC sign-in flow. Requires mkcert:
#   brew install mkcert
set -euo pipefail


# The hostname the development server answers on. The same name must appear:
#   - in the `etc/hosts` file.
#   - as a registered redirect URI on the Entra ID application.
CERT_HOST=local.app-url.northwestern.edu


# Trusts a local authority in the system store, so browsers accept the cert.
# Idempotent, so re-running it is harmless.
mkcert -install


# Everything below is written here.
# A locally-generated certificate or private key MUST NOT be committed,
# so add this directory to `.gitignore` -- see Tracked Files.
mkdir -p certs


# Issues the cert for that hostname, signed by the authority installed above,
# and writes it along with private key. The dev server is configured with both.
mkcert \
    -cert-file "certs/$CERT_HOST.pem" \
    -key-file  "certs/$CERT_HOST-key.pem" \
    "$CERT_HOST"


# Python's clients verify against certifi, not the system store.
# Concatenating the roots keeps public certs verifiable alongside the local one.
cat "$(python -m certifi)" "$(mkcert -CAROOT)/rootCA.pem" > certs/ca-bundle.pem
```

The development server is then pointed at that certificate, and listens on the port named in the registered redirect URI.

By framework

**Django**

`manage.py runserver` does not serve HTTPS at all. `runserver_plus`, from [`django-extensions`](https://django-extensions.readthedocs.io/), does:

```bash
python manage.py runserver_plus \
    --cert-file certs/local.app-url.northwestern.edu.pem \
    --key-file  certs/local.app-url.northwestern.edu-key.pem \
    local.app-url.northwestern.edu:8000
```

**Flask**

`flask run` serves HTTPS directly:

```bash
flask run \
    --cert certs/local.app-url.northwestern.edu.pem \
    --key  certs/local.app-url.northwestern.edu-key.pem \
    --host local.app-url.northwestern.edu \
    --port 8000
```

**FastAPI**

Uvicorn serves HTTPS directly:

```bash
uvicorn myapp.main:app \
    --ssl-certfile certs/local.app-url.northwestern.edu.pem \
    --ssl-keyfile  certs/local.app-url.northwestern.edu-key.pem \
    --host local.app-url.northwestern.edu \
    --port 8000
```

One further step is specific to Python: its HTTP clients do not read the system trust store. `requests` and `httpx` both verify against the `certifi` bundle, so a locally-trusted certificate is invisible to them: a browser accepts the development server while a test calling that same server fails to verify.

Where a project makes HTTPS requests to its own development server, clients SHOULD be pointed at a bundle containing both `certifi`’s roots and the local authority’s. Pointing a client at the local root on its own replaces the bundle rather than adding to it, which breaks verification of every public certificate – the identity provider’s included.

Each client is then pointed at the result, either through the environment or in code. Neither the variable nor the argument is shared between the two:

**requests**

`requests` reads `REQUESTS_CA_BUNDLE`, falling back to `CURL_CA_BUNDLE` where that is unset.

```bash
export REQUESTS_CA_BUNDLE="$PWD/certs/ca-bundle.pem"
```

The bundle can be set on the session instead, which needs no environment at all.

```python
session = requests.Session()
session.verify = "certs/ca-bundle.pem"
```

**httpx**

`httpx` reads `SSL_CERT_FILE`, or `SSL_CERT_DIR` for a directory of roots rather than a single file.

```bash
export SSL_CERT_FILE="$PWD/certs/ca-bundle.pem"
```

The bundle can be passed to the client instead. `httpx` has deprecated the plain path form, so the context is built explicitly.

```python
import ssl


client = httpx.Client(
    verify=ssl.create_default_context(cafile="certs/ca-bundle.pem"),
)
```

A value set in code takes precedence over the environment in both clients, which keeps verification independent of whatever the developer’s shell happens to hold.

#### Impersonation

User impersonation MAY be enabled for administrators in deployed environments. Where it is, it MUST be restricted to a known set of users, and every impersonation event MUST be logged. A student MUST NOT be able to find a deployed environment and switch into an administrative account to reach data they are not entitled to.

The mechanism is left to the project. Django’s admin has [`django-hijack`](https://django-hijack.readthedocs.io/); Flask and FastAPI have no comparable convention, so a project on either MUST implement the restriction and the logging itself.

> **PY-09 · For SDCC Review - Impersonation: Python or General?**
>
> The requirements above – restrict to a known set of users, log every event, keep a student out of an administrative account – name no Python API. The [Laravel standards](https://sdcc.entapp.northwestern.edu/coding-standards/laravel/#authentication) already state the same three in their own wording, so the rule is maintained in two places and can drift in either. The [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#authentication) do not mention impersonation at all.
>
> The note above on `django-hijack` is the Python-specific part and would stay here.
>
> See also [PY-00](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-00).

#### Non-Northwestern Users

Some applications need participation from people without NetIDs – parents, alumni, collaborators at other institutions.

Where possible, such users SHOULD be given [affiliate, departmental, or guest NetIDs](https://services.northwestern.edu/TDClient/30/Portal/Requests/ServiceOffering/26/Affiliate-Departmental-and-Guest-NetIDs) so that they authenticate the same way everyone else does. Access by users who are not authenticated through SSO SHOULD be limited to the specific information they need.

The expectation set out under [Authentication](https://sdcc.entapp.northwestern.edu/coding-standards/python/#authentication) is unchanged by any of this: Northwestern users sign in through SSO, and a login path built for non-Northwestern users MUST NOT be reachable with a NetID.

Where an application does hold credentials of its own for such users, it MUST store them using its framework’s password hasher or a maintained library, and MUST NOT build its own hashing on top of `hashlib`.

> **PY-10 · For SDCC Review - Non-Northwestern users: Python or General?**
>
> Affiliate NetIDs, limiting what an unauthenticated user can reach, and keeping a non-Northwestern login path unreachable with a NetID are policy rather than Python. The [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#non-northwestern-users) already carry a “No Recommendation” note on exactly this – *“There are no recommendations for login methods in this section. Maybe there should be?”* – so the gap is acknowledged; this document has filled it locally.
>
> Only the last paragraph is Python’s: use the framework’s password hasher, do not build one on `hashlib`.
>
> See also [PY-00](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-00).

### Authorization

> **PY-11 · For SDCC Review - Authorization: Python or General?**
>
> The General Coding Standards have no Authorization section: this one mirrors the Laravel document rather than specializing a general requirement. Most of the section is genuinely Python-specific, but the opening principle is not – and with no general statement to specialize, the two stack documents have already diverged. The [Laravel standards](https://sdcc.entapp.northwestern.edu/coding-standards/laravel/#authorization) say an application SHOULD implement a role-based access control system; this document says to authorize against permissions rather than roles.
>
> Both may be defensible, since the packages each ecosystem reaches for differ. But the divergence is an accident rather than a decision, and a developer moving between the two stacks has no way to tell which it is.
>
> Unlike the others, this one cannot be answered by moving text: there is no general section to move it to. Should one exist?
>
> See also [PY-00](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-00).

Applications SHOULD authorize against permissions rather than roles. A permission names something the code does, while a role names a group of people.

Roles remain the mechanism for granting permissions in bulk, and SHOULD be used for that.

**Prefer: Permission Check**

```python
if user.has_perm('reports.publish_report'):
    ...
```

**Avoid: Role Check**

```python
if user.groups.filter(name='Editor').exists():
    ...
```

> **Why?**
>
> The mapping between roles and permissions may change over time. Checking the permission at the point of use makes individual capabilities more transparent, and keeps churn out of the codebase.

> **Already built? — Improve as you go.**
>
> An application checking roles directly does not need converting in one pass. Call sites SHOULD move to permission checks as they are touched, and new call sites SHOULD NOT add role checks.

Python frameworks vary greatly in how they supply permission-checking capabilities:

| Framework | Supplies                                                                           | Where a check belongs                                      |
| --------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Django    | Users, groups, and per-model permissions in `django.contrib.auth`                  | A decorator or mixin on the view                           |
| Flask     | No permission system, but the `before_request` hook is a natural place to hang one | A hook on the blueprint, or a decorator the project writes |
| FastAPI   | Nothing built in, though dependency injection is the natural seam                  | A dependency that raises `403`                             |

Authorization in all three frameworks is opt-in: an endpoint with no check is reachable by anyone who has signed in. A forgotten check therefore fails open, and raises no error while doing it – the page simply works for everybody. Projects SHOULD make protection structural rather than remembered, so that a new endpoint inherits it instead of needing it added.

By framework

**Django**

Projects SHOULD use the permission system in `django.contrib.auth` rather than building one alongside it. Views SHOULD carry `PermissionRequiredMixin` or `@permission_required`.

```python
# Import block
# Assumes a `Report` model and `django.contrib.auth` in INSTALLED_APPS,
# which generates the `reports.view_report` permission automatically.
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins     import PermissionRequiredMixin
from django.http                    import HttpRequest, HttpResponse
from django.views.generic           import DetailView


# Class-based views.
# The required permission is declared as an attribute.
class ReportDetail(PermissionRequiredMixin, DetailView):
    model = Report
    permission_required = "reports.view_report"


    # Refuse with a 403, rather than a login redirect.
    raise_exception = True


# Function-based views.
# The required permission is declared as a decorator.
# `raise_exception` refuses with a 403, rather than a login redirect.
@permission_required("reports.publish_report", raise_exception=True)
def publish_report(request: HttpRequest, pk: int) -> HttpResponse:
    ...
```

A project MAY route its views through a base class that requires a permission by default, so that omitting one is a visible choice rather than an oversight.

```python
# Import block
# Assumes the same `Report` model as above.
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.views.generic       import DetailView


# Mixed into every view in the project, ahead of its generic base.
# `PermissionRequiredMixin` raises `ImproperlyConfigured` when
# `permission_required` is unset, so a view that forgets one fails on its first
# request rather than being reachable by anyone signed in.
class AuthorizedMixin(PermissionRequiredMixin):
    raise_exception = True


# A view mixing this in declares only its own permission.
class ReportDetail(AuthorizedMixin, DetailView):
    model = Report
    permission_required = "reports.view_report"
```

Its permissions are per-model, so object-level checks – “may edit their own report” – need a package such as [`django-guardian`](https://django-guardian.readthedocs.io/) or [`django-rules`](https://github.com/dfunckt/django-rules).

A few behaviors are worth knowing, because they are not visible in the code that relies on them:

* `is_superuser` short-circuits `has_perm`, which returns true for every permission and bypasses custom backends entirely, so an application that reasons carefully about permissions still grants a superuser everything.
* Enabling the Django admin introduces a second authorization system, gated on `is_staff`, which does not consult the application’s own checks. `is_staff` MUST NOT be treated as meaning “an administrator of this application”.
* Both the mixin and the decorator redirect to the login page by default, even for a signed-in user who simply lacks the permission. Using `raise_exception = True` refuses with a 403 instead.

**Flask**

Flask has no built-in features for role or permission management, so projects will have to assemble those features themselves.

Authorization rules SHOULD live in one module rather than scattered through view functions, so that they can be read, reviewed, and tested on their own.

A blueprint’s `before_request` hook applies to every view registered beneath it, which is the seam that makes protection structural: a route added to a guarded blueprint inherits the check rather than needing its own.

```python
# Import block
# Assumes a local module with a function `may` that checks user permissions,
# and that something earlier in the request has set `g.user`.
from flask           import Blueprint, abort, g
from ..authorization import may


# Define the blueprint for this module.
reports = Blueprint("reports", __name__, url_prefix="/reports")


# Runs for every view on this blueprint, including ones added later.
@reports.before_request
def guard() -> None:
    if not may(g.user, "reports.view_report"):
        abort(403)


# As part of the `reports` blueprint, this endpoint will inherit
# the user-permission protections in `guard()`, specifically viewing reports.
@reports.get("/<int:report_id>")
def detail(report_id: int) -> str:
    ...
```

**FastAPI**

FastAPI has no built-in features for role or permission management, so projects will have to assemble those features themselves.

Dependency injection is the natural seam. An authorization check SHOULD be written as a dependency that raises `HTTPException(403)` rather than as a branch inside the path operation – a dependency can be tested on its own, and a branch cannot.

Routers accept `dependencies` that apply to every route beneath them, so a guarded router protects the endpoints added to it later.

```python
# Import block
# Assumes a local module with a function `may` that checks user permissions,
# a `current_user` dependency that resolves the signed-in user,
# and a `Report` response model.
from collections.abc import Callable
from typing          import Annotated
from fastapi         import APIRouter, Depends, HTTPException, status
from ..authorization import may
from ..security      import current_user


# Returns a dependency rather than being one, so the permission can be named
# where it is used. The result is an ordinary callable, testable on its own.
def require(permission: str) -> Callable[..., None]:
    def dependency(user: Annotated[User, Depends(current_user)]) -> None:
        if not may(user, permission):
            raise HTTPException(status.HTTP_403_FORBIDDEN)
    return dependency


# Define the router for this module.
# Dependencies here are applied to all views on this route.
reports = APIRouter(
    prefix       = "/reports",
    dependencies = [ Depends(require("reports.view_report")) ],
)


# As part of the `reports` router, this endpoint will inherit the protections
# declared on the router, specifically viewing reports.
@reports.get("/{report_id}")
async def detail(report_id: int) -> Report:
    ...
```

Authorization tests SHOULD cover refusal as well as access. A suite proving that the right people get in, without proving that the wrong people are turned away, has tested half the requirement. pytest’s parameterization suits this well: one test, a list of roles, an expected status for each. See [Automated Tests](https://sdcc.entapp.northwestern.edu/coding-standards/python/#automated-tests) for the tools.

#### Roles from Entra ID

Where roles originate in Entra ID rather than in the application’s own records, the claims SHOULD be mapped onto the framework’s permission system at sign-in rather than read directly at each call site, so that the rest of the application authorizes the same way wherever the roles came from. `mozilla-django-oidc` exposes hooks on its authentication backend for this.

Entra omits the group claim when a user belongs to more groups than it will serialize, sending an overage indicator in its place. An application that reads the claim and finds it absent MUST NOT treat that as “no groups”: it MUST either resolve the groups through Microsoft Graph or fail closed.

> **PY-12 · For SDCC Review - How stale may a synced role be?**
>
> Mapping claims at sign-in means a user removed from a group keeps the permission until they sign in again. Whether that is acceptable depends on what the permission grants, and the alternative – re-reading on every request – costs either a Graph call or a larger token. Is there a session length past which roles need re-checking?

### Accessibility

User-facing applications SHOULD be designed with accessibility in mind from the start, and SHOULD be validated as they are built rather than audited once at the end. Review the guidance in the [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#accessibility) document regarding accessibility standards.

> **Already built? — No exceptions.**
>
> The WCAG 2.1 AA requirement comes from the University’s Digital Accessibility Policy, which draws no distinction between new and existing applications. An application that predates this document is still subject to it, and remediation SHOULD be planned rather than deferred indefinitely.

An application’s obligation under this section depends on whether it renders its own markup.

Accessibility constrains not just how tools are used, but how they are chosen in the first place: an application cannot be made compliant if it does not control the markup it serves. See [Web Applications](https://sdcc.entapp.northwestern.edu/coding-standards/python/#web-applications) for where that rules out data-app tools such as Streamlit for user-facing work.

Where an application serves an API to a separate front end – that is, where the final markup is not under the control of the app itself – the requirement falls on whatever system does render it.

#### Automated Checks

Accessibility checks SHOULD run as part of the Playwright suite described under [Automated Tests](https://sdcc.entapp.northwestern.edu/coding-standards/python/#automated-tests), against every page the application serves.

The usual engine for this is `axe-core`, which is injected into the page and run against the rendered DOM. New rules are added to it over time, so the version a project runs decides which violations the checks are able to report. There are two ways to obtain the engine, and a repository SHOULD select one:

1. **Wrapper Package:** A package such as `axe-playwright-python` supplies the fixture and a report format, alongside a bundled `axe-core`. The project writes less, at the cost of inheriting the wrapper’s `axe-core` version and maintenance: a wrapper may lag behind the latest rules, and one that stops receiving updates leaves the ruleset at whatever version it last shipped.
2. **Direct Dependency:** `axe-core` is declared in the project’s `package.json` and injected from `node_modules`. The project pins and updates the ruleset through its own JavaScript lockfile, at the cost of requiring Node in an image that may otherwise not need it.

Accessibility problems SHOULD prevent the tests from passing, and they SHOULD be fixed before merging or deploying. See [Code Review](https://sdcc.entapp.northwestern.edu/coding-standards/python/#code-review) for where those checks gate merging.

The pages to audit are listed in `tests/routes.py`. Whether that list actually covers every page is the subject of [Auditing Every Page](https://sdcc.entapp.northwestern.edu/coding-standards/python/#auditing-every-page).

**conftest.py**

```python
# Assumes `pytest-playwright`, which supplies the `page` fixture used below.
from   collections.abc      import Callable
from   pathlib              import Path
from   playwright.sync_api  import Page
import pytest


# `axe-core` is declared in `package.json`; npm pins the version.
# This assumes the Direct Dependency approach, under Automated Checks.
AXE = Path("node_modules") / "axe-core" / "axe.min.js"


# Defined here rather than in a test module so that any page test can audit
# the page it just exercised, without repeating the setup.
@pytest.fixture
def audit(page: Page) -> Callable[[str], list[dict]]:
    def run(url: str) -> list[dict]:
        response = page.goto(url)


        # A login redirect or a 404 audits the wrong markup and reports success,
        # so confirm the page under test is the page that loaded.
        # A route that redirects by design needs its destination audited instead.
        assert response is not None and response.ok, (
            f"{url} returned {response and response.status}"
        )
        assert page.url == url, f"{url} redirected to {page.url}"


        # axe runs in the browser against the live DOM.
        # Injection follows navigation because `goto` replaces the document,
        # taking any earlier script with it.
        page.add_script_tag(path=str(AXE))


        # `runOnly` limits the report to the conformance levels the University's
        # policy requires. Left out, axe also reports its best-practice rules,
        # which are not part of WCAG 2.1 AA -- and a report full of items nobody
        # is obliged to fix is a report people stop reading.
        #
        # `page.evaluate` awaits the promise that `axe.run` returns.
        results = page.evaluate('''
            axe.run(document, {
                runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'],
            })
        ''')
        return results["violations"]


    return run
```

**tests/test\_accessibility.py**

```python
# Assumes the following:
#   - an `audit` fixture from `conftest.py`
#   - a `live_server` fixture serving the application
# `pytest-django` supplies one; Flask and FastAPI projects write their own.
from   fnmatch import fnmatchcase
import json
import pytest


# Defined under Auditing Every Page; `registered_routes` varies by framework.
from .routes import AUDITED, EXEMPT, registered_routes




# One test per page, so a failure names the page that failed and the remaining
# pages still run instead of stopping at the first problem.
@pytest.mark.parametrize("url", list(AUDITED.values()), ids=list(AUDITED))
def test_page_is_accessible(audit, live_server, url: str) -> None:


    # `AUDITED` holds paths rather than absolute URLs, so the host comes from the
    # fixture -- the same list works whatever port the test server picks.
    violations = audit(f"{live_server.url}{url}")


    # The serialized report is the assertion message, so a CI log names the
    # rule that failed and the element that failed it.
    assert violations == [], json.dumps(violations, indent=2)




# Adding a page without auditing it fails here rather than going unnoticed.
# See Auditing Every Page for how `registered_routes` reads the routing table.
def test_every_route_is_audited() -> None:
    registered = registered_routes()


    # `fnmatchcase` rather than `fnmatch`: the latter folds case on some platforms,
    # so the same list would behave differently on Windows.
    def exempt(route: str) -> bool:
        return any(fnmatchcase(route, pattern) for pattern in EXEMPT)


    # Named rather than asserted inline: pytest prints local variables on failure,
    # so a failing run shows which routes were involved, not just that some were.
    unaudited     = {r for r in registered if r not in AUDITED and not exempt(r)}
    stale_audits  = AUDITED.keys() - registered
    stale_exempts = {
        p for p in EXEMPT if not any(fnmatchcase(r, p) for r in registered)
    }


    assert not unaudited,     "pages nobody is checking"
    assert not stale_audits,  "audited entries for routes that no longer exist"
    assert not stale_exempts, "exemptions matching no route"
```

**.github/workflows/ci.yml**

```yaml
# The steps an accessibility suite adds to a workflow.
# See Automated Tests for the rest of the pipeline.


name: Accessibility


on:
  pull_request:


jobs:
  accessibility:
    # ...


    steps:
      # ...


      # Playwright's browsers are not Python packages, so a locked dependency
      # set does not bring them along. `--with-deps` also installs the system
      # libraries a clean runner is missing; without it the browser fails to
      # launch and every page test errors before axe ever runs.
      - name: Install browsers
        run: uv run playwright install --with-deps chromium


      # Accessibility failures are ordinary test failures, and gate the pull
      # request the same way the rest of the suite does.
      - name: Run accessibility checks
        run: uv run pytest tests/test_accessibility.py


      # ...
```

#### Manual Testing

Automated checks establish a baseline; they do not establish compliance. `axe-core` reports what can be determined from the markup, which is less than compliance requires:

| `axe-core` can determine…                            | A person has to judge…                                 |
| ---------------------------------------------------- | ------------------------------------------------------ |
| …that a form control *has* a label.                  | …whether the label *describes* the control.            |
| …that `aria-describedby` *points at* a real element. | …whether that element *explains* the referenced field. |
| …that text *meets* the contrast ratio.               | …whether color alone *carries meaning*.                |
| …that nothing *overrides* the natural tab order.     | …whether tabbing *follows* the reading order.          |

Projects SHOULD test with a keyboard, and SHOULD exercise their primary user journeys with a screen reader. Review the guidance in the [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#accessibility) document regarding what automated tools can and cannot establish.

> **PY-13 · For SDCC Review - Manual accessibility testing: Python or General?**
>
> The requirement above says to test with a keyboard and a screen reader, but not how much, how often, or by whom. None of that is Python-specific. The [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#accessibility) ask for automated tools and note their limits, but set no expectation for manual testing.
>
> Is there guidance to point at – a cadence, a set of journeys that must be covered, or a point at which a manual finding blocks a release the way a failing `axe-core` check blocks a merge?
>
> See also [PY-00](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-00).

#### Auditing Every Page

A test suite can only give results for the pages that it visited. For this reason, projects SHOULD be explicit about whether or not each route is audited:

* A list of audited routes SHOULD be checked against the application’s own routing table, so that adding a page without auditing it fails.

* Routes that genuinely cannot be audited SHOULD be listed as exemptions rather than omitted, so that the gap in coverage is visible. The check treats an exemption exactly like an audited page, so the exemption list is the one place coverage can shrink without the check noticing: entries SHOULD be removed over time, and SHOULD NOT be added for new pages.

The mechanism is left to the project. One possible arrangement is a module declaring both the audited pages and the exemptions, with the comparison left to the test that imports them:

**tests/routes.py**

```python
# Every page this suite audits, and the routes deliberately left out.
# See Auditing Every Page for how each framework reports its routing table.


# Keyed by the identifier the routing table uses, so the two can be compared.
# The value is the URL to visit: a route taking a parameter needs one a fixture
# has made real, which is why this list cannot simply be generated.
AUDITED = {
    "reports:list":   "/reports/",
    "reports:detail": "/reports/1/",
}


# Routes deliberately not audited, as patterns matched against the identifiers.
# Meant to shrink: entries come out as the matching pages become auditable,
# and new pages do not go in.
EXEMPT = {
    "health-check",  # returns JSON, renders no markup
    "admin:*",       # third-party markup, not ours to fix
}
```

A project with many parameterless pages MAY derive their URLs from the routing table rather than listing them, leaving only routes that need a fixture-created object to be written by hand. Each framework provides the lookup: `reverse()` in Django, `url_for()` in Flask, `url_path_for()` in FastAPI.

Each framework exposes its routing table differently, and identifies routes in a form of its own. That form is usually composed rather than the bare name a route was declared with, and a mis-keyed entry is reported as a route that no longer exists rather than as a key that does not match.

By framework

**Django**

Django has no built-in, documented API for listing its routing table. The function `django.urls.reverse()` turns a route name into a URL, but nothing public lists the names in the first place.

The list of routes SHOULD be derived from a tool that reads the URLconf, rather than from Django’s internals. For example, `django-extensions` provides a `show_urls` management command with machine-readable output that a test can consume.

A route is identified by its namespace and its `name`, joined by a colon, so that two applications can each declare a route with the same name. For example, a route declared `name="detail"` inside an `include()` with `namespace="reports"` is identified as `reports:detail`.

Routes SHOULD be named, which Django already expects for `reverse()` and `{% url %}`. An unnamed `path()` gives the list nothing to key on, and is absent from the enumeration entirely – so the coverage check cannot report it as unaudited, and the page is simply never checked. A name also survives a change to the URL pattern, so the list does not churn when paths are reorganized.

```python
# Assumes `django-extensions` as a development dependency.
import json
from   io                     import StringIO
from   django.core.management import call_command


# `show_urls` reads the URLconf, including namespaced includes.
def registered_routes() -> set[str]:
    output = StringIO()
    call_command("show_urls", format="json", stdout=output)
    routes = json.loads(output.getvalue())
    return { route["name"] for route in routes if route["name"] }
```

Review Django’s [URL dispatcher](https://docs.djangoproject.com/en/stable/topics/http/urls/) documentation regarding route names and namespaces.

**Flask**

`url_map` holds every rule registered on the application and on its blueprints, so a blueprint registered later is covered without changing this code.

A route is identified by its endpoint: the blueprint’s name and the view function’s name, joined by a dot. For example, a view named `detail()` on a blueprint named `reports` is identified as `reports.detail`.

Rules carry the methods they accept, which is what lets a POST-only endpoint be filtered out: it processes a submission rather than serving a page.

```python
# Assumes the configured application instance.
from myapp import app


# `iter_rules` covers the app and every registered blueprint.
# Flask's own static route is excluded: it serves no markup.
def registered_routes() -> set[str]:
    return {
        rule.endpoint
        for rule in app.url_map.iter_rules()
        if "GET" in rule.methods and rule.endpoint != "static"
    }
```

**FastAPI**

The OpenAPI document FastAPI already generates is the application’s own list of paths, so nothing needs enumerating separately.

A route is identified by its path template rather than by a name, since the OpenAPI document is organized by path. A route declared `@reports.get("/{report_id}")` on a router with `prefix="/reports"` is identified as `/reports/{report_id}`. The parameter stays as written, so the identifier and the URL the audit visits are not the same string.

Paths carry the methods they accept, which is what lets a POST-only endpoint be filtered out: it processes a submission rather than serving a page.

An application serving only JSON has no pages to audit.

```python
# Assumes the application instance the routers are registered on.
from myapp.main import app


# The generated schema, keyed by the route patterns.
def registered_routes() -> set[str]:
    paths = app.openapi()["paths"]
    return { path for path, ops in paths.items() if "get" in ops }
```

#### Server-Rendered Pages

A server-rendered application writes its own markup, so a failing check usually points at a template rather than at application logic. A fix applied to a shared template corrects every page that uses it, which often makes remediation cheaper than the number of failing pages suggests.

The guidance below concerns forms, since they are where a framework most often generates markup on the project’s behalf. The same reasoning covers anything else it generates: pagination controls, breadcrumbs, or the widgets a template tag renders.

Regardless of how it is rendered, a form field needs its label, its help text, and its errors associated with the input rather than merely adjacent to it:

```html
<label for="email">Email</label>
<input id="email" aria-describedby="email-help email-error" aria-invalid="true">
<p id="email-help">We will only use this to contact you about your report.</p>
<p id="email-error">Enter an email address.</p>
```

Frameworks vary in how much of this markup they supply:

By framework

**Django**

The forms framework renders labels, help text, and errors, and associates the label with its input. Help-text association has improved across recent releases, so a project SHOULD confirm what its own version emits rather than assuming.

Taking over the rendering transfers that responsibility to the project. Rendering fields individually, or adopting a package such as `django-crispy-forms` or `django-widget-tweaks`, makes it the project’s responsibility to maintain these associations – usually with no indication that anything was lost.

The Django admin is a second body of markup the project does not write. Where it is exposed to staff rather than developers, its accessibility is inherited along with it.

**Flask**

Flask generates no markup of its own; Jinja2 renders the template as written.

`flask-wtf` and WTForms are the common addition, and they cover part of it: rendering `field.label` and the widget produces a label already associated with its input. Errors are not covered – `field.errors` is a list the template renders where it chooses, so `aria-describedby` and `aria-invalid` remain the project’s to set.

Nothing is generated beyond that, so no association changes when Flask or WTForms is upgraded.

**FastAPI**

FastAPI has no templating of its own. Where it serves pages, `Jinja2Templates` renders the template as written, and no form library convention comes with it.

More often it backs a separate front end, in which case the markup is not the application’s to control.

### Cookies

In line with the General Coding Standards on [Cookies](https://sdcc.entapp.northwestern.edu/coding-standards/general/#cookies), applications are expected to set only necessary cookies, to scope them to their own subdomain, to mark them `secure` and `httpOnly`, to give them a sensible expiry, and to treat every cookie arriving from a browser as untrusted input.

Whichever framework an application uses, cookies SHOULD be set and read through that framework’s own API rather than by writing or parsing headers directly. The [`Cookie` and `Set-Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies) syntaxes have more edge cases than they appear to, and the framework API already implements them.

Two protections matter here, and they are not interchangeable:

* A **signature** makes an alteration detectable, so a tampered cookie is rejected; it leaves the value readable.
* **Encryption** makes the value unreadable, and is not something any framework in common use does for a cookie.

Review [OWASP’s Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) regarding both.

A cookie is readable by anyone who holds it, and a signature does not change that. Applications MUST NOT place data in a cookie unencrypted where being read by its holder would matter. Such data SHOULD be kept server-side and referenced by an opaque identifier; where it is kept in the cookie instead, it MUST be encrypted rather than merely signed. Server-side is preferred because encryption means owning key management and rotation, which no framework here provides.

> **PY-14 · For SDCC Review - Is MUST NOT ours to set?**
>
> The [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#cookies) ask only that applications SHOULD consider signing sensitive cookies, and say nothing about whether a cookie’s contents may be read at all. The requirement above is stronger than that, on an axis the general standards do not address.
>
> It also has teeth. Where a framework keeps the session in the cookie by default, an application holding personal data in a default session is out of compliance from the day this is published, and the remedy is a new dependency or a store written by hand.
>
> So: is MUST NOT the right level here, or SHOULD NOT? And if MUST NOT is right, should it be stated in the general standards instead, so that every stack inherits it rather than each one setting its own bar?
>
> See also [PY-15](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-15), whose answer follows from this one.

> **Already built? — Improve as you go.**
>
> An existing application keeping personal data in a client-side session SHOULD move that data server-side. The change is a session backend setting rather than a rewrite, though it invalidates every active session, so it SHOULD be scheduled rather than slipped in.

> **PY-15 · For SDCC Review - How urgent is moving a session server-side?**
>
> The note above treats a client-side session holding personal data as work to schedule. It could as easily be read as a disclosure to fix at once, since the contents are readable by anyone who holds the cookie, then or later. Which is it, and does the answer depend on what the session holds?

> **PY-16 · For SDCC Review - Cookie management?**
>
> Should we provide any guidance on cookie *management*, e.g. requiring that users can clear site cookies? If so, should this go in the general documentation?

#### Session Storage

A session is a cookie, so the rules above apply to it. However, unlike a cookie the application sets deliberately, the contents of a session may live in different locations based on framework’s default settings. A project SHOULD confirm which default it has rather than assume.

By framework

**Django**

Session data is stored server-side by default and the cookie carries only an opaque key, so nothing about the session is visible to the browser.

The `signed_cookies` backend, set through `SESSION_ENGINE`, moves the data into the cookie instead. It is signed rather than encrypted, so adopting it makes every session value readable by its holder.

```python
# Stored in the session table; the cookie carries only a key.
request.session["report_id"] = 17
```

**Flask**

Flask stores the session in the cookie itself. The contents are serialized, signed with `SECRET_KEY`, and sent to the browser, so any value written to `session` is readable by that user.

`flask-session` moves the data server-side – backed by a database, Redis, or the filesystem – leaving only an identifier in the cookie.

```python
# Stored in the cookie itself, signed but readable by the user.
session["report_id"] = 17
```

**FastAPI**

FastAPI has no session of its own. Starlette’s `SessionMiddleware` signs the session into the cookie and sends it to the browser, so its contents are readable by the user.

There is no built-in server-side backend. A project needing one stores the data itself and keeps only an identifier in the session.

```python
# Stored in the cookie itself, signed but readable by the user.
request.session["report_id"] = 17
```

#### Signed Cookies

A cookie SHOULD be signed where a forged value would change what the application does. Without a signature, a value altered in the browser would be accepted as though it were the original value set by the application.

| Cookie Content                                 | Effect if Forged                            | Impact               |
| ---------------------------------------------- | ------------------------------------------- | -------------------- |
| a dismissed-banner flag                        | the user sees a banner they had hidden      | contained            |
| a record identifier the application then loads | the user reaches another user’s data        | disclosure           |
| a role or entitlement                          | the user gains access they were not granted | privilege escalation |

A signature carries a timestamp marking its creation, and covers that timestamp along with the value – a client cannot alter either without invalidating the cookie. The age of a signed cookie is therefore something the application can check for itself. A `max_age` given when a signed cookie is set does not mean the same thing as one given when it is read:

* When the cookie is **set**, it is the **browser’s expiry**, i.e. how long the browser is asked to keep sending the cookie. A client can ignore this age and continue sending the cookie anyway.
* When the cookie is **read**, it is the **signature’s age limit**, i.e. the oldest signature that the application will still accept. A client cannot make an old signature look new.

The reader’s `max_age` is the only one the application enforces, and SHOULD be set wherever a signed cookie is read. An application MAY set the browser’s expiry as well; where it does, it SHOULD match the signature’s age limit, so the browser stops sending the cookie at the same point the application stops accepting it.

Setting a cookie does not sign it by default; both the signature and the `max_age` have to be asked for explicitly. How this is done differs by framework:

By framework

**Django**

Django provides a signed variant of the cookie API: `set_signed_cookie()` signs with `SECRET_KEY`, and `get_signed_cookie()` rejects a value that has been altered. `set_cookie()` signs nothing.

A value whose alteration would matter can be handled in one of two ways:

1. **The value can live in the session.** The session is stored server-side by default, so the value never reaches the browser at all – see [Session Storage](https://sdcc.entapp.northwestern.edu/coding-standards/python/#session-storage).
2. **The value can be signed explicitly.** `set_signed_cookie()` needs no additional dependency.

Either solution is acceptable; leaving the value unsigned is not.

**Prefer: Session Storage**

```python
# Server-side by default: the value never reaches the browser.
request.session["report_id"] = 17


# An altered cookie matches no stored session,
# so this raises KeyError rather than returning a forged value.
request.session["report_id"]
```

**Accept: Signed Cookie**

```python
# The maximum valid age for this cookie (30 days).
# Asked of the browser on set; enforced by the application on read.
MAX_AGE = 60 * 60 * 24 * 30


# Signed: the browser cannot change this value without being noticed.
response.set_signed_cookie("report_id", "17", max_age=MAX_AGE)


# Raises:
#   - BadSignature      -- if the value was altered.
#   - SignatureExpired  -- if the signature is older than MAX_AGE.
request.get_signed_cookie("report_id", max_age=MAX_AGE)
```

**Avoid: Unsigned Cookie**

```python
# The expiry requested for this cookie (30 days).
# Asked of the browser only -- an unsigned cookie has no age to check.
MAX_AGE = 60 * 60 * 24 * 30


# Unsigned: the browser can change this and the application will not notice.
response.set_cookie("report_id", "17", max_age=MAX_AGE)


# ⚠️ Returns whatever the browser sent, altered or not.
request.COOKIES["report_id"]
```

**Flask**

Flask does not provide a signed variant of the cookie API: `set_cookie()` writes the value as given.

A value whose alteration would matter can be handled in one of two ways:

1. **The value can live in the session.** This is simpler, since the session is already signed – see [Session Storage](https://sdcc.entapp.northwestern.edu/coding-standards/python/#session-storage).
2. **The value can be signed explicitly.** This requires signing the value before the cookie is set, e.g. through the `itsdangerous` library (already a Flask dependency).

Either solution is acceptable; leaving the value unsigned is not.

**Prefer: Session Storage**

```python
# Signed: the session is signed by default.
session["report_id"] = 17


# An altered cookie invalidates the whole session,
# so this raises KeyError rather than returning a forged value.
session["report_id"]
```

**Accept: Signed Cookie**

```python
from itsdangerous import URLSafeTimedSerializer


# The maximum valid age for this cookie (30 days).
# Asked of the browser on set; enforced by the application on read.
MAX_AGE = 60 * 60 * 24 * 30


# Signed: the browser cannot change this value without being noticed.
signer = URLSafeTimedSerializer(app.secret_key)
response.set_cookie("report_id", signer.dumps("17"), max_age=MAX_AGE)


# Raises:
#   - BadSignature      -- if the value was altered.
#   - SignatureExpired  -- if the signature is older than MAX_AGE.
signer.loads(request.cookies["report_id"], max_age=MAX_AGE)
```

**Avoid: Unsigned Cookie**

```python
# The expiry requested for this cookie (30 days).
# Asked of the browser only -- an unsigned cookie has no age to check.
MAX_AGE = 60 * 60 * 24 * 30


# Unsigned: the browser can change this and the application will not notice.
response.set_cookie("report_id", "17", max_age=MAX_AGE)


# ⚠️ Returns whatever the browser sent, altered or not.
request.cookies["report_id"]
```

**FastAPI**

FastAPI does not provide a signed variant of the cookie API: `set_cookie()` writes the value as given.

A value whose alteration would matter can be handled in one of two ways:

1. **The value can live in the session.** This is simpler, since `SessionMiddleware` signs the session already – see [Session Storage](https://sdcc.entapp.northwestern.edu/coding-standards/python/#session-storage).
2. **The value can be signed explicitly.** This requires signing the value before the cookie is set, e.g. through the `itsdangerous` library (a Starlette dependency wherever `SessionMiddleware` is used).

Either solution is acceptable; leaving the value unsigned is not.

**Prefer: Session Storage**

```python
# Signed by `SessionMiddleware`.
request.session["report_id"] = 17


# An altered cookie invalidates the whole session,
# so this raises KeyError rather than returning a forged value.
request.session["report_id"]
```

**Accept: Signed Cookie**

```python
from itsdangerous import URLSafeTimedSerializer


# The maximum valid age for this cookie (30 days).
# Asked of the browser on set; enforced by the application on read.
MAX_AGE = 60 * 60 * 24 * 30


# Signed: the browser cannot change this value without being noticed.
signer = URLSafeTimedSerializer(SECRET_KEY)
response.set_cookie("report_id", signer.dumps("17"), max_age=MAX_AGE)


# Raises:
#   - BadSignature      -- if the value was altered.
#   - SignatureExpired  -- if the signature is older than MAX_AGE.
signer.loads(request.cookies["report_id"], max_age=MAX_AGE)
```

**Avoid: Unsigned Cookie**

```python
# The expiry requested for this cookie (30 days).
# Asked of the browser only -- an unsigned cookie has no age to check.
MAX_AGE = 60 * 60 * 24 * 30


# Unsigned: the browser can change this and the application will not notice.
response.set_cookie("report_id", "17", max_age=MAX_AGE)


# ⚠️ Returns whatever the browser sent, altered or not.
request.cookies["report_id"]
```

#### Cookie Settings

The flags required by the [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#cookies) are configuration rather than code, and SHOULD be set explicitly rather than left to a framework default. An explicit setting keeps its value when a framework changes its defaults.

By framework

**Django**

Settings live in `settings.py`.

`CSRF_COOKIE_HTTPONLY` is left off deliberately. Django’s documented approach for AJAX requests reads the `csrftoken` cookie from JavaScript and echoes it in an `X-CSRFToken` header, which `httpOnly` would prevent. A project that only submits forms rendered with `{% csrf_token %}` never needs that access and MAY enable it.

settings.py

```python
SESSION_COOKIE_SECURE   = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_AGE      = 60 * 60 * 8


CSRF_COOKIE_SECURE      = True
```

**Flask**

Settings are read from `app.config`.

When `SESSION_COOKIE_SAMESITE` is unset, Flask omits the attribute from the cookie rather than supplying a value, which leaves the behavior to whatever the browser defaults to. Setting it returns that decision to the application. `PERMANENT_SESSION_LIFETIME` applies only to sessions marked permanent; an ordinary session lasts until the browser closes.

```python
app.config.update(
    SESSION_COOKIE_SECURE      = True,
    SESSION_COOKIE_HTTPONLY    = True,
    SESSION_COOKIE_SAMESITE    = "Lax",
    PERMANENT_SESSION_LIFETIME = timedelta(hours=8),
)
```

**FastAPI**

The cookie flags are arguments to `SessionMiddleware` rather than configuration constants. The cookie is always `httpOnly`, which is not configurable.

```python
app.add_middleware(
    SessionMiddleware,
    secret_key = SECRET_KEY,
    https_only = True,
    same_site  = "lax",
    max_age    = 60 * 60 * 8,
)
```

> **PY-17 · For SDCC Review - SameSite: Python or General?**
>
> Every framework above exposes a `SameSite` setting and it is central to CSRF defense, but nothing about choosing a value is Python-specific. The [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#cookies) require `secure`, `httpOnly`, and a sensible expiry, but stop short of `SameSite`.
>
> Nothing would stay behind but the three code samples above.
>
> See also [PY-00](https://sdcc.entapp.northwestern.edu/coding-standards/python/#PY-00).

#### Cookies From Other Applications

A cookie set by another application can still be read through the framework’s ordinary cookie accessor; however, it cannot be verified, since the signature was made with another application’s key.

When reading such a cookie, the signature check is the only thing that can be set aside. Being unable to verify a cookie is not the same as being unable to read it, and the `Cookie` header SHOULD NOT be parsed by hand here any more than anywhere else.

By framework

**Django**

Django offers two accessors, and only one of them applies here. `get_signed_cookie()` checks the value against `SECRET_KEY` and rejects a cookie it did not sign; `request.COOKIES` returns the value as the browser sent it.

```python
# Set by this application, so the signature can be checked.
request.get_signed_cookie("banner_dismissed")


# Set elsewhere: readable, but not verifiable.
request.COOKIES["foobar"]
```

**Flask**

`request.cookies` is the only accessor, so there is nothing to avoid: a foreign cookie is read the same way as any other.

```python
# Set elsewhere: readable, but not verifiable.
request.cookies["foobar"]
```

**FastAPI**

Starlette’s `request.cookies` is the only accessor, so there is nothing to avoid: a foreign cookie is read the same way as any other.

```python
# Set elsewhere: readable, but not verifiable.
request.cookies["foobar"]
```

At Northwestern the common case is the `nusso` cookie, which carries single sign-on state for applications on the PingAM path described under [Authentication](https://sdcc.entapp.northwestern.edu/coding-standards/python/#authentication).

Such a cookie is input from a browser like any other. Applications MUST validate it against the issuing service rather than trusting its contents.

The same limitation applies in the other direction: a cookie set for another application to read cannot usefully be signed, since that application holds no key with which to verify it. That leaves two cases, and nothing in between:

* Where the receiving application has to trust the value, a shared cookie is not the right mechanism. Instead, the value SHOULD come from a common issuer and be validated against that issuer, as `nusso` is above.
* Where a forged value would not change what either application does, no signature is needed, as under [Signed Cookies](https://sdcc.entapp.northwestern.edu/coding-standards/python/#signed-cookies).

> **Why not share a signing key?**
>
> A framework’s signing key is the application’s master secret, covering sessions and tamper-protected tokens as well as cookies, so sharing it would grant another application the ability to forge sessions in this one.
>
> A shared symmetric key also proves less than it appears to: it identifies a holder of the key rather than the application that set the value, and every party to the arrangement is a holder.

## Logging

Sensitive data (passwords, API keys, private keys, etc.) MUST NOT be printed into the application logs. This includes direct logging, as well as data in exception objects and stack traces.

The framework’s built in logging system SHOULD be used for general log messages. Login logging MAY use the general logging facility, provided the log storage backend retains the logs long enough. Review the guidance in the [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#logging) document regarding the duration of time that login attempts should be retained.

### Suspicious Activity

Applications SHOULD log a message when a user performs a suspicious operation. Review the guidance in the [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#suspicious-activity) document regarding what counts as suspicious activity.

### Error Monitoring

[Sentry](https://sentry.io/) MAY be used for error monitoring. Python applications use the [Python SDK](https://docs.sentry.io/platforms/python/) for backend error tracking.

Review the guidance in the [General Coding Standards](https://sdcc.entapp.northwestern.edu/coding-standards/general/#sentry) document regarding the use of Sentry.

## Dependency Management

TODO

## Configuration Management

Configuration SHOULD NOT be hard-coded into an application. Environment-specific configuration files MAY be included in a repository, so long as they do not contain secret values.

Secrets such as passwords, API keys, etc MUST NOT be included in version control. If written to a local `.env` file, they should be explicitly excluded from version control (e.g. in `.gitignore` for Git). Secrets may also be retrieved by an external secret (store?), (e.g. GCP Secret Manager).
