Repository navigation
Perfect-Session 4.0.0 is the first tagged release of the Swift 6 rewrite. It replaces 3.1.5 (2018). It consolidates the old per-backend repositories into one package with a product per backend: PerfectSessionCore, PerfectSessionMySQL, PerfectSessionPostgreSQL, PerfectSessionRedis, PerfectSessionSQLite and PerfectSessionMongoDB.
.package(url: "https://github.com/PerfectlySoft/Perfect-Session.git", from: "4.0.0"),Requirements
- Swift 6.2 or later (
swift-tools-version: 6.2, Swift 6 language mode). - macOS 12 or later, or Linux. CI covers Swift 6.2 and 6.4 on Ubuntu 24.04, in debug and release, with every backend running against a live server. It also covers Xcode 26.0.1 and 26.6 on macOS.
- Dependencies: Perfect-MySQL 4.0.0, Perfect-PostgreSQL 5.0.0, Perfect-Redis 4.0.0, Perfect-SQLite 6.0.0 and Perfect-MongoDB 4.0.3 (all
from:), plus swift-log (the core now uses it too). Each backend needs its own client library: libmysqlclient, libpq, libsqlite3 on Linux, or libmongoc 2. Redis needs none. Only the backends you depend on get built.
Highlights
- One package, five backends, all behind an
async,SendableSessionDriverprotocol. The core has no database or HTTP-framework dependency. - Server-side expiry. Every driver's
resumenow refuses an expired session (a deliberate change, see below), and every driver'ssaverenews the idle time. A new optionalSessionConfig.absoluteTimeoutcaps a session's lifetime from creation;clean(), the MongoDB TTL index and Redis TTLs honour it too. regenerate(_:)on every driver moves a session to a new token and a new CSRF token, keeping its creation time. Call it at sign-in to prevent session fixation.- CSRF and authentication helpers:
CSRFSecurity.tokenMatches(_:_:)(a timing-independent token comparison), a stricterCSRFSecurity.isValid(scheme, default ports, case), andAuthFilter.requiresAuthentication(_:using:), which matches by path segment and ignores case. The README has a Perfect-NIO middleware example that uses all three. - Logs never contain session tokens: the drivers log
PerfectSession.logID(_:), a 12-digit SHA-256 prefix. SessionCookiebuilds theSet-Cookieheader values fromSessionConfig. It enforcesSecureforSameSite=Noneand for the__Secure-/__Host-prefixes (in any case), and refuses values that would inject attributes.- 256-bit tokens from the system CSPRNG (
PerfectSession.newToken()). - Works with Perfect-NIO 0.1.0. The README has a session-filter example. A scratch app built against Perfect-NIO 0.1.0 with warnings as errors on Swift 6.2.4 and 6.4, and ran end to end: cookie set, resume, regenerate on sign-in, and the old token refused. A NIO 0.1.0 server gated by
requiresAuthentication("/admin")answered 401 for/Admin,/ADMIN,/Admin/usersand the other case and slash variants; with the oldshouldWeAccept, the review got 200 and the protected content for those. - MongoDB backend (new since 3.x):
datais stored as a real subdocument, with a TTL index.savedoesn't resurrect logged-out sessions,setup()purges legacy Perfect-Session-MongoDB documents, and awkward keys are escaped.
Breaking and behaviour changes
- The 3.x module and its HTTP filters are gone. That covers
PerfectSessionwith Perfect-HTTPServer'sSessionMemoryDriverrequest and response filters,CSRFFilterandisValid(_ request:). Upgrade: depend onPerfectSessionCoreplus a backend product, and write a small middleware that calls aSessionDriver,SessionCookie,CSRFSecurity,CORSSecurityandAuthFilter(see the README's Perfect-NIO example). - Drivers are
async.resumethrows, andsave(session:)becamesave(_:). Upgrade:try await driver.resume(token:),await driver.save(s)andawait driver.destroy(token:). - The separate Perfect-Session-MySQL, -PostgreSQL, -Redis, -SQLite and -MongoDB packages are superseded. Upgrade: switch to this package's products. Sessions stored by 3.x or by Perfect-Session-MongoDB aren't migrated, so users sign in again. The MongoDB backend's
setup()deletes legacy documents from a shared collection. resumethrowsInvalidSessionErrorfor an expired session. This is deliberate (Tim's decision). It used to return the session and leave the check toisValid(). Upgrade: nothing for most apps. If you relied on resuming expired sessions, read them from storage directly.- Every driver's
saverenews the idle time (touch()); only Redis did, so elsewhere a session expiredidleseconds after creation however active it was. 3.1.5's memory driver also touched on save. Upgrade: none; if you relied on a fixed lifetime from creation, setSessionConfig.absoluteTimeout. - Redis calls time out after 5 seconds by default (
RedisSessionConnector.commandTimeout, was no limit; Tim's decision). A Redis command that takes longer now throws and its connection is closed:create/save/destroylog it, andresumethrowsInvalidSessionError. Upgrade: setcommandTimeoutto a larger value, ornilfor no limit. PerfectSessionManageris nowSessionDriver,asyncandSendable, and also requirescreate(ipaddress:useragent:)(setup,cleanandregeneratehave default implementations). Upgrade: conform custom stores toSessionDriverand implementcreate.SessionConfig.couchDatabaseandmongoCollectionare removed. Upgrade: use the backend's connector (MongoDBSessionConnector.collection).SessionConfig.cookieSameSiteis aCookieSameSite(was Perfect-HTTP'sHTTPCookie.SameSite), andCORS.methodsis[String](was[HTTPMethod]). Upgrade:.strict/.lax/.nonekeep working; write methods as strings (["GET", "POST"]).- Settings with no effect in 4.0 are deprecated:
SessionConfig.CSRF.failAction,checkState,checkHeadersandrequireToken;SessionConfig.CORS.methods,customHeaders,withCredentialsandmaxAge;SessionConfig.purgeInterval,healthCheckRouteandisOAuth2;PerfectSession._isOAuth2;AuthenticationConfig.denied. Nothing reads them, so setting them (in 3.x,requireTokenenforced the CSRF token) does nothing. Upgrade: do it in your middleware:CSRFSecurity.tokenMatchesandisValidfor CSRF, your ownAccess-Control-*headers for CORS,clean()on your own schedule. AuthFilter.shouldWeAcceptis deprecated forrequiresAuthentication(_:using:)(same meaning: true means authentication is required), and matching changed: paths are compared by/segment ignoring empty and.segments and, by default, case;"/admin"covers everything below it but not/administrator;*is a wildcard only as a whole final segment (an inclusion that uses it otherwise requires authentication everywhere; such an exclusion matches nothing); when anything is protected, paths with..,%,;or\require authentication. Upgrade: check your patterns; setAuthenticationConfig.caseInsensitive = falseonly if your router is case-sensitive.CSRFSecurity.isValidcompares the scheme (anhttp://origin no longer passes for anhttps://host), treats:443/:80like no port, ignores case, and rejects anullorigin. Upgrade: list exact origins inacceptableHostnames, with a scheme if you want to restrict it.- A session-data value JSON can't hold drops only its own key (logged by name), not all of
data; a non-JSONcsrfis replaced.resumeadds a CSRF token to a stored session that lacks one. Upgrade: none. - Tokens are 64 lowercase hex characters, not UUIDs. Upgrade: if anything validated the UUID format, accept 64 hex characters instead. Every backend's column or key already fits.
- Redis keys now have a prefix:
RedisSessionConnector.keyPrefix + token, defaultperfect-session:. Upgrade: sessions written by the pre-releasemainaren't found, so users sign in again. SettingkeyPrefix = ""keeps the old keys but brings back the key-space exposure described under Fixes. - Redis TTL follows the session's own
idle, notSessionConfig.idle. A session withidle≤ 0 is now deleted instead of being stored forever. Upgrade: setidleon the session or inSessionConfig.idleas before. Don't use 0 to mean "never"; use a large value. MemorySessionDriver.saveonly updates a stored session, like the other drivers. Upgrade: create sessions throughcreate(), not by constructingPerfectSessionwith your own token.- New SQL tables use 64-bit
created/updated/idlecolumns (MySQLbigint, PostgreSQLint8). Tables thatsetup()created before 4.0 keep their 32-bit columns, and still work (verified on MySQL 8.4 and PostgreSQL 17). Upgrade: run the README'sALTER TABLEto support anidlebeyond about 68 years and times after 2038. isValid,CSRFSecurity.isValidandCORSSecurity.isAcceptablegained optional parameters for explicit lock and hostname config, defaulting toSessionConfig. Upgrade: none; existing calls compile unchanged.SessionDrivergained aregenerate(_:)requirement, with a default implementation. Upgrade: none for custom drivers, unless one already had aregeneratemethod with a different meaning.- CORS wildcard patterns now have to match on both sides of the
*(see Fixes). Upgrade: if you relied onhttps://*matching any https origin, list your origins, or use"*"deliberately.
Fixes
- AuthFilter: behind Perfect-NIO 0.1.0, whose router ignores case,
/Admin,/ADMINand/Admin/usersreached/adminhandlers without authentication (the review got 200s from a live app);//admin,/./admin,/%61dminand/admin;x=1weren't matched;"*.json"never matched and"/admin*"covered/administrator. (#17) - CSRF token loss: one non-JSON value in
session.datamade every backend but memory save the session with no data at all, CSRF token included, while the user stayed signed in. (#17) - Idle renewal:
saverenewed the idle time only on Redis. (#17) regeneraterestarted the absolute timeout. (#17)- Tokens in logs: the Redis, SQLite and MongoDB drivers logged full session tokens as
eventid. (#17) - Redis: an
idlenearInt.maxmadesavefail and lose the session's data; the TTL is capped at about 31,700 years. (#17) - Redis: a server that stopped answering held requests indefinitely (now a 5 s default timeout). (#17)
- CSRF origin check: ignored the scheme, and treated default ports and Host case as differences. (#17)
SessionCookie:__HOST-/__secure-names (other cases) weren't recognized. (#17)- absoluteTimeout wasn't honoured by
clean(), the MongoDB TTL index or Redis TTLs. (#17) - Redis: a client-supplied token could name any key in the Redis database.
resumewould turn an application's JSON value with auseridinto a session for that user, and logging out with that token deleted the key. Keys are now namespaced. (#17) - Redis:
saveafterdestroyrecreated the session: a logout racing a save brought it back. It now usesSET … XX. (#17) - Redis: the TTL ignored the session's
idle, and anidle≤ 0 made a session immortal. (#17) - Linux: every new session could get the same token. Foundation's
UUID()on Linux (Swift 6.2 and 6.4) ignores a failed open of/dev/urandom, so in a process out of file descriptors every UUID is00000000-0000-4000-8000-000000000000. Reproduced in swift:6.2-noble and swift:6.4-noble containers. Tokens now come fromSystemRandomNumberGenerator. (#17) - Crash: storing a
Date,URL, NaN, infinity or a non-string-keyed dictionary insession.dataand saving terminated the process. Darwin's JSONSerialization raises an uncatchable NSException. Now only the keys holding such values are dropped (their names are logged, never their values), and the CSRF token is always kept. (#17) - CORS:
"https://*.example.com"accepted every https origin, because the prefix or the suffix was enough. (#17) - Memory driver:
saverecreated destroyed sessions, and stored sessions built around client-chosen tokens. (#17) - Crash: an
idleofInt.maxtrappedisValid(),MemorySessionDriver.clean()and the MongoDB document builder on overflow. Perfect-MongoDB's BSON encoder also trapped on the resulting far-future date, so MongoDB'sexpiresAtis now clamped to year 9999. (#17) - MySQL/PostgreSQL: 32-bit columns made an
idlebeyondInt32.maxfail to store, silently, and integer overflow inupdated + idlemadeclean()fail on every row once one session had a huge idle. (#17) - PostgreSQL:
connectionString()left@ / : ? =unencoded, so a password containing one of them couldn't connect. (#17) - MySQL: a negative
MySQLSessionConnector.porttrapped. (#17) - Tests: test-only data races on
SessionConfig, found by ThreadSanitizer. The checks now take explicit config values. (#17) - MongoDB: no resurrection on save, legacy-document purge, and empty,
%and NUL keys escaped. (#16) - MongoDB: the new backend. (#14)
- MySQL: deprecated
close()calls replaced with an explicit lifetime. (#13) - Swift 6 rewrite and consolidation of the backends. (#12)
- README: cleanup and Chinese translation. (#15)
- Pins and CI: dependencies pinned to the released versions. CI now runs the Swift 6.2/6.4 × debug/release matrix with warnings as errors,
TZ=America/Denver,contents: readand timeouts, and the live tests fail instead of skipping when a server is missing (SESSION_REQUIRE_LIVE). (#17)
Verified with no code change: SQL text in tokens, user IDs and data is bound as parameters on MySQL, PostgreSQL and SQLite. MongoDB stores $-prefixed and dotted keys as data, and operator-like tokens match nothing. Redis tokens containing spaces, colons, CR/LF or RESP framing are just bytes. Concurrent save and resume of one session is last-writer-wins with no corruption on every backend. Guard Malloc and ThreadSanitizer runs were clean on macOS. MongoDB's TTL monitor runs about once a minute, so resume checks expiry itself.
Known limitations
- The MySQL backend stores session data in a
TEXTcolumn (64 KiB): a larger save is lost. Keep session data small, or change the column toMEDIUMTEXT. AuthFiltercase-folds with full Unicode lowercasing; with a router that folds Unicode differently, a non-ASCII path could be judged differently. Perfect-NIO 0.1.0 refuses non-ASCII request targets, so it isn't reachable there. Passreq.path, not the raw URI (a?or#in the input isn't treated specially).regeneratecreates the new session before destroying the old one; if the destroy fails (e.g. a Redis timeout), the old token can stay valid until it expires.- A NUL in a user ID or user agent can't be stored by the PostgreSQL backend (the write fails), and the SQLite backend cuts the value off at it.
datais unaffected. AuthFilterdoesn't percent-decode: when anything is protected, a path containing%requires authentication. Pass the path without its query string.- The MySQL and PostgreSQL connectors have no TLS/
sslmodeoptions; connections use the client library's defaults (libpq'spreferdoesn't verify the server). Use a trusted network, a socket or a TLS tunnel. - MySQL compares tokens with the column's collation (case-insensitive by default), so an uppercased token also resumes a session. Tokens are random lowercase hex, so this doesn't weaken them meaningfully.
CORSSecurityonly says whether an origin is acceptable: a trailing*in a pattern also matches look-alike hosts,"*"acceptsnull, and theAccess-Control-*headers (credentials included) are the app's job.SessionConfig,AuthFilter.authenticationConfigand the connectors arenonisolated(unsafe)globals. Set them at startup, not while serving requests.PerfectSessionis@unchecked Sendablebecausedatais[String: Any]; keep it to JSON values.MongoDBSessionDriver()traps on an invalidMongoDBSessionConnector.uri; useinit(uri:database:collection:), which throws.- The MySQL and PostgreSQL drivers don't log database errors: a failed write is silent.
Upgrade notes
- From 3.x: see Breaking changes. Plan a sign-in for all users, because stored sessions don't carry over.
- From the pre-release
main: run the README'sALTER TABLEfor the MySQL and PostgreSQL tables, and expect Redis sessions to be dropped once (key prefix). Callregenerate(_:)at sign-in, and useSessionCookiefor the cookie header.
Legacy
The pre-Swift-6 code is on the legacy branch: 3.1.5 plus PR #10's OAuth2 flag. Stay on 3.x if you can't move to Swift 6.2.