You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

Sessions

A session is what a browser carries between requests once a user has signed in: a few values, usually just who the user is, that the server can trust because the client cannot forge them. AppOptions.Sessions turns them on, and ctx.Session() returns the request's session.

Sessions are opt-in. An application that leaves the option unset pays nothing for it, and one that sets it still pays nothing on a request whose handler, guards and providers never call ctx.Session(): no cookie is parsed, nothing is decrypted, no store is asked and nothing is written.

Configuring them

app := muzak.New(muzak.AppOptions{
    Title: settings.AppName,
    // Sessions, kept in a cookie encrypted under the configured secrets.
    Sessions: &muzak.SessionOptions{Secrets: settings.SessionSecrets},
    // Required with sessions; see "Cross-origin protection is required" below.
    CrossOriginProtection: &muzak.CrossOriginOptions{},
})

Generate a secret with openssl rand -base64 32 and keep it out of the source. Nothing else needs setting: every default below is the safe one.

The example application gives SESSION_SECRETS a default, so that it runs as it is. A deployment that keeps that default has sessions anyone who has read the source can forge: set your own before you deploy it.

FieldDefaultEffect
SecretsnoneThe keys the cookie store encrypts with, each at least 32 bytes (muzak.MinSessionSecretLength). The first encrypts, all decrypt. Required by the cookie store, unused by a server-side store
Storethe encrypted cookieA SessionStore that keeps sessions on the server; the cookie then carries only a random identifier
Name__Host-sessionThe cookie's name; see The cookie
Path/The cookie's path. A narrower one is not a security boundary, and gives up the __Host- prefix
DomainnoneSends the cookie to every subdomain too, and gives up the __Host- prefix
SameSiteLaxStrict also withholds the cookie from links followed from other sites; None needs Secure
AllowInsecureoffSends the cookie without Secure, for development on a host other than localhost
IdleTimeout2 hoursEnds a session that has not been used for this long
MaxLifetime24 hoursEnds a session this long after it began, however busy
MaxSizewhat fits in a cookie, or 16 KiB with a storeThe largest encoded session; a change past it fails where it is made
StoreTimeout2 secondsBounds each call to a SessionStore

Everything that cannot be served as written is a build error, reported with the others: no secret or a short one, a negative duration or size, an idle timeout longer than the lifetime, a MaxSize larger than a cookie can carry, a Path that does not begin with /, SameSite=None without Secure, http.SameSiteDefaultMode (which leaves the choice to each browser), a name that is not a token, a __Host- or __Secure- name whose attributes a browser would refuse, and a name, path and domain so long that the cookie has room for less than 256 bytes of session. No secret is ever quoted in the message.

Signing in

The example application signs in with a form posted to /login/, and signs out at /logout/:

func Login(ctx *muzak.Context, in schemas.LoginIn) (schemas.LoginOut, error) {
    expected, known := accounts[in.Username]
    matches := subtle.ConstantTimeCompare([]byte(expected), []byte(in.Password)) == 1
    if !known || !matches {
        return schemas.LoginOut{}, muzak.Unauthorized("the username or password is incorrect")
    }

    session := ctx.Session()
    // Regenerating comes first, before anything that says who the user is.
    if err := session.Regenerate(); err != nil {
        return schemas.LoginOut{}, err
    }
    if err := session.Set("user", in.Username); err != nil {
        return schemas.LoginOut{}, err
    }
    return schemas.LoginOut{Username: in.Username, Next: in.Next}, nil
}

Always regenerate on sign-in

Call Regenerate on every sign-in, before you set anything that says who the user is. Without it, session fixation works: an attacker gets a session of their own, plants it in the victim's browser (through a sibling subdomain when the cookie names a Domain, through a page served over plain HTTP on the same host when it is not Secure, or through a cross-site scripting bug), and waits. When the victim signs in, the session the attacker already holds becomes the victim's signed-in session. Regenerate gives the values a new session, a new identifier with the old one deleted from a server-side store, and a new ciphertext and lifetime with the cookie store, so whatever the attacker holds is worthless at the moment it would have become valuable. The default __Host- cookie closes the first two routes; regenerating closes all of them. Call it whenever the privilege a session carries changes, such as stepping up to an administrator, as well.

The session is not repeated in the response body. A credential in a body is one any script on the page can read, which is exactly what the cookie's HttpOnly attribute exists to prevent.

Reading and writing values

A session holds string keys and JSON values. Set encodes a value, and muzak.SessionGet decodes one into the type asked for:

func Profile(ctx *muzak.Context, _ muzak.Empty) (ProfileOut, error) {
    user, ok := muzak.SessionGet[string](ctx.Session(), "user")
    if !ok {
        return ProfileOut{}, muzak.Unauthorized("sign in first")
    }
    return loadProfile(ctx.Context(), user)
}
CallDoes
muzak.SessionGet[T](s, key)The value under key decoded as T, and whether there was one. A value that does not decode as T, because an earlier release stored something else there, reads as absent
s.Has(key)Whether there is a value, without decoding it
s.Set(key, value)Stores value as JSON. Fails for an empty key, one that is not UTF-8, a value JSON cannot encode, a session that would grow past MaxSize (muzak.ErrSessionTooLarge) and a session whose store could not be read; the session is left as it was
s.Delete(key)Removes one value
s.Clear()Removes every value. An emptied session is removed from the browser rather than written empty
s.IsNew()Whether the request came without a valid session, or its session has been destroyed since, which is how a handler tells an anonymous visitor
s.Err()Why a server-side store could not be read; see Server-side stores

Keep a session small: an identifier and a preference or two. It travels with every request, and with the cookie store it is the cookie. A change that would make it too large is refused where it is made rather than silently truncated on the way to the browser, so the error reaches the code that caused it.

A *Session, like the *muzak.Context it came from, is not safe for concurrent use and must not be used once the handler has returned. A session kept past its request refuses every change.

Signing out

func Logout(ctx *muzak.Context, _ muzak.Empty) (muzak.Empty, error) {
    ctx.Session().Destroy()
    return muzak.Empty{}, nil
}

Destroy removes every value, removes the cookie from the browser and, with a server-side store, deletes the session's entry, so a copy of the cookie taken earlier is worthless too. A value set afterwards in the same request begins a new session under a new identifier.

The cookie store cannot revoke. Its session is the cookie, so a copy taken before Destroy ran, by malware or from a shared machine, stays valid until its idle timeout or lifetime ends it, and nothing the server holds can say otherwise. An application that must end a session everywhere, at sign-out, on a password change or when an account is suspended, wants a server-side store.

When the session is written

The session is written at most once per request, at one point: after the handler has returned, after every dependency release has run, and before the response is written. A transaction that fails to commit therefore takes the session change with it. What is written, and when:

  • Only a request that succeeded. A handler that returns an error, one that answers with a status of 400 or above, one that panics and one whose release fails write nothing, so a half finished request never leaves a half changed session.
  • Only a session that changed, or is due for renewal. Reading a session writes nothing. A session in use is renewed once a tenth of its idle timeout has passed since it was written, so a busy user's cookie is rewritten every twelve minutes at the default, not on every request.
  • Never a session the request did not touch.
  • Once. The response never carries two cookies for one session.

Save writes the session immediately, for a change that must be kept whatever happens next, such as a count of failed sign-in attempts or a message for the next page:

attempts, _ := muzak.SessionGet[int](s, "attempts")
_ = s.Set("attempts", attempts+1)
if err := s.Save(); err != nil {
    return LoginOut{}, err
}
return LoginOut{}, muzak.Unauthorized("the username or password is incorrect")

A change made after Save is written when the request ends, if it succeeds, and replaces what Save wrote.

A handler that writes its response itself, through ctx.ResponseWriter() or by returning a muzak.Stream, has its session written as the status line goes out, if that status is below 400, because a cookie cannot follow the first byte. A change made after that is not saved. An event stream and a WebSocket begin their response before their handler runs, so a session changed there is not saved either; read the session in a guard and change it in an ordinary route. Either way the lost change is logged once as a warning naming the route, unless the response it would have gone out with had a status of 400 or above, which discards a change on purpose. Save after the response has started returns an error.

A response that carries the session cookie is marked Cache-Control: private, no-cache, replacing a policy that would let a shared cache keep it, since a cache that stored it would hand one user's session to the next. A Cache-Control that already says private or no-store is left alone. This holds whatever sets Cache-Control after the cookie: a handler that sets a public policy after Save, an error renderer that lets an error be cached, or the file server dropping the header from a range it cannot serve (see Files, Streams and Redirects). The response is made private again as its header goes out, and an event stream's no-transform is kept when it is.

A response from a handler that read the session varies on Cookie and is marked the same way unless the handler set a Cache-Control of its own. An event stream and a WebSocket keep their own Cache-Control when a guard or provider reads the session.

Set-Cookie: __Host-session=AUYbFNGP...; Path=/; Max-Age=7200; HttpOnly; Secure; SameSite=Lax
  • HttpOnly keeps it from scripts, so a cross-site scripting bug cannot read it.
  • Secure keeps it off plain HTTP, where anyone on the network can read it. Browsers accept a Secure cookie from http://localhost, so development needs nothing; AllowInsecure exists for another host over plain HTTP, and cannot be combined with SameSite=None or a prefixed name.
  • SameSite=Lax keeps it off requests other sites start, except a top-level navigation by GET. It does not keep it off a request from a sibling subdomain, which is the same site; that is what cross-origin protection is for.
  • The __Host- prefix is the one most applications never think about. A browser accepts a cookie with that name only from a secure origin, only with Path=/ and only without a Domain. A sibling subdomain, which can otherwise set a cookie for the whole domain, and a page served over plain HTTP on the same host, which can otherwise set a cookie of the same name, can set neither. That is what stops an attacker from planting a session of their choosing in the user's browser, or shadowing the real one with their own.
  • Max-Age is how long the session has left, the idle timeout or what remains of the lifetime. It only tells the browser when to drop the cookie: the timeouts themselves are kept inside the encrypted or stored session, never taken from the cookie, which the client controls.

Setting Domain, or a Path other than /, gives up the __Host- prefix and the default name becomes __Secure-session; setting AllowInsecure gives up both prefixes and it becomes session. A name you choose is checked against the same rules, without regard to case, as browsers check them.

With no Store, the session is the cookie: its values and its two timestamps, encrypted and authenticated with AES-256-GCM and encoded as unpadded base64url.

  • Each secret is reduced to a key with HKDF-SHA256 when the application is built.
  • Each cookie draws a fresh random 192-bit nonce, from which HKDF derives an AES-256 key and a GCM nonce used for that cookie alone. AES-GCM with a random 96-bit nonce under one key is good for about 2^32 messages before a repeated nonce becomes a concern, and a repeated nonce lets an attacker forge cookies; a busy application renewing sessions could get there. A key per cookie removes the limit.
  • The cookie's name, the format's version and a label naming the purpose are bound as additional data, so a cookie cannot be decrypted under another name, by another use of the same secrets or as another version of the format. A cookie lifted from one application's __Host-session and presented to another as __Host-admin is refused, even with the same secrets.

A cookie that was tampered with anywhere, truncated, extended, spelled differently, encrypted under a secret no longer listed, or past its idle timeout or lifetime reads as no session: the request proceeds signed out, and nothing is logged of the cookie or its contents. It is never an error page, because a client can send any cookie it likes and an error would only tell it which of them were interesting.

Rotating a secret

  1. Put the new secret first and keep the old one after it. New cookies are encrypted under the new one; existing cookies still open under the old one.
  2. Wait for MaxLifetime to pass, 24 hours by default. No cookie encrypted under the old secret can still be valid.
  3. Remove the old secret.

Decrypting tries each secret in turn, so keep the list short. To invalidate every session at once, after a leak say, replace the secrets rather than adding to them.

How much fits

The cookie must stay within 4096 bytes, name and attributes included, which is the most every browser is sure to keep: one past it is dropped by some of them without a word. With the default name that leaves a little under 3 KB of encoded JSON. MaxSize defaults to that, and a larger value is a build error. An application that needs more wants a server-side store.

Server-side stores

app := muzak.New(muzak.AppOptions{
    Sessions: &muzak.SessionOptions{
        Store: muzak.NewMemorySessionStore(muzak.MemorySessionOptions{MaxEntries: 50_000}),
    },
    CrossOriginProtection: &muzak.CrossOriginOptions{},
})

With a Store, the cookie carries only a random 256-bit identifier, and the store holds the session under the SHA-256 digest of that identifier, never the identifier itself. A backup or a dump of the store therefore holds no cookie anyone could present, and since nobody can choose a digest, how long a lookup takes says nothing about which identifiers exist. An identifier a client sends that the store does not know is never adopted: the session the request begins gets an identifier of the application's own. A cookie that cannot be an identifier this application issued is not looked up at all.

MemorySessionStore keeps sessions in the process. It is for tests and for an application that runs as one process: sessions are not shared with a second replica, and a restart signs everyone out. It holds at most MaxEntries sessions (10,000 by default), discards the least recently used when full, which signs that user out, and discards an entry once its time has passed, a few at a time as it is used, so it starts no goroutine. Every operation is constant time.

Writing a store

type SessionStore interface {
    Load(ctx context.Context, key string) (data []byte, found bool, err error)
    Create(ctx context.Context, key string, data []byte, ttl time.Duration) error
    Update(ctx context.Context, key string, data []byte, ttl time.Duration) (found bool, err error)
    Delete(ctx context.Context, key string) error
}

The data is opaque, and carries its own timestamps, which are checked against the idle timeout and the lifetime whatever the store returns. The TTL is how long the store may keep the entry.

There is no single Save, and that is deliberate. A request that read a session just before the user signed out in another tab would otherwise write the session back as it finished, and the sign-out would not stick. Create saves a new session, and Update saves only over one that still exists, reporting false otherwise. Against Redis that is SET with NX and with XX; against SQL, an INSERT and an UPDATE whose row count is checked:

func (s *RedisSessions) Create(ctx context.Context, key string, data []byte, ttl time.Duration) error {
    ok, err := s.client.SetNX(ctx, "session:"+key, data, ttl).Result()
    if err == nil && !ok {
        err = errors.New("session key already exists")
    }
    return err
}

func (s *RedisSessions) Update(ctx context.Context, key string, data []byte, ttl time.Duration) (bool, error) {
    return s.client.SetXX(ctx, "session:"+key, data, ttl).Result()
}

Every call must return when its context ends; each is bounded by StoreTimeout, and the writes are not cancelled by a client hanging up, so a sign-out is not abandoned half way. A store that also implements Lifecycle is started with the application and stopped after it drains.

A store that cannot be read does not sign the user out and then save over their session. The request proceeds with an empty session that refuses every change, s.Err() reports muzak.ErrSessionUnavailable wrapping the store's error, and the failure is logged without the identifier. Destroy still works: the cookie is removed and the deletion attempted. A handler that would rather fail than serve such a request signed out checks for it:

if err := ctx.Session().Err(); err != nil {
    return Out{}, err
}

A store that fails a write fails the request with a 500, as a release that fails would, and the work registered with AfterResponse does not run. A sign-out whose deletion fails still removes the browser's cookie, and still answers 500, so the user knows the session may outlive it.

Timeouts

The idle timeout and the lifetime are both kept inside the session, as the time it began and the time it was last written, and both are checked every time it is read.

  • IdleTimeout, 2 hours by default, ends a session that has not been used for that long. Because a session in use is only rewritten every tenth of the timeout, it lapses between nine tenths of the timeout and the whole of it after its last use.
  • MaxLifetime, 24 hours by default, ends a session that long after it began, however busy. It is what bounds a stolen cookie that its thief keeps alive. Regenerate starts it again.

A session that has expired reads as no session, and the session the request begins replaces it.

Two requests, one session

Two requests carrying the same session each get a copy, and whichever writes last decides what is kept, its cookie or its entry in the store. Neither ever writes half of one: a store must keep whole records, and MemorySessionStore does. With a server-side store, a request that finishes after another destroyed or regenerated the session finds it gone and writes nothing, neither the entry nor a cookie that would overwrite the browser's newer one, so a sign-out in one tab is not undone by a request from another (with the one exception under Limits):

  • What it read goes too. A Save that finds the session gone leaves it new and empty, so a value set later in the same request begins a new session holding nothing the ended one held.
  • Regenerate checks first. Regenerating creates a new entry rather than updating the old one, which would not show that the old one was gone, so the old entry is looked up before it is replaced. When another request has ended the session, nothing is written and the request succeeds without a session; when the store cannot answer, the request fails with 500.

Cross-origin protection is required

A session cookie is an ambient credential: the browser attaches it to a request whichever page made it. SameSite=Lax keeps it off a form another site posts, but not off one posted from a sibling subdomain, and SameSite=None keeps it off nothing. Sessions are therefore a build error without AppOptions.CrossOriginProtection:

muzak: AppOptions.Sessions is set without AppOptions.CrossOriginProtection; a session cookie is
sent with requests other pages make, so state-changing requests must be checked for where they
came from. Set CrossOriginProtection: &muzak.CrossOriginOptions{}, ...

It is one line, it needs no token in any form, and it refuses nothing a same-origin page does. See Cross-Origin Requests.

What a hostile client cannot do

AttemptWhat happens
Edit, truncate or extend the cookieAuthenticated encryption refuses it; the request is anonymous
Present a cookie from another application or another cookie nameThe name is bound into the encryption; refused
Keep using a cookie past its timeout, or set a longer Max-AgeThe times are inside the authenticated session and checked on every read
Plant a session before the victim signs inRegenerate replaces it; __Host- stops subdomains and plain HTTP from planting one
Invent an identifier for a server-side storeNever adopted; a new session gets the application's own identifier
Read a stolen store dump for cookiesThe store holds digests of identifiers, not identifiers
Flood the server with sessionsNothing is stored for a visitor until a handler sets a value; the memory store is bounded and evicts the least recently used
Send a huge cookieRefused unread past 4096 bytes; every check is linear in the cookie
Get a session cached and served to othersA response carrying the cookie is private, and one that read the session varies on Cookie
Forge a request from another siteRefused by cross-origin protection before anything runs

Limits

  • One store, one application. A server-side store holds each session under the digest of its identifier and nothing else: neither the application nor the cookie's name is part of the key. Two applications given one store, or two configurations with different cookie names, accept each other's sessions. Give each its own store, or its own key prefix in a store you write (the "session:" in the Redis example above is where it goes).
  • Regenerate leaves a short race. The old entry is looked up before it is replaced, and a sign-out that lands between the lookup and the replacement is undone. The window is as long as one call to the store.
  • A client that hangs up during a load is logged at debug level. Loading runs under the request's context, so a client that disconnects while the store is being read ends the load; that is logged at debug level, as a stream its client ended is, while a load that fails or outruns StoreTimeout is logged at error level as "the session store could not be read".

Testing

The test client's cookie jar plays the browser, and treats the loopback address it serves on as secure, as browsers treat localhost, so the default Secure __Host- cookie works unchanged:

func TestSignIn(t *testing.T) {
    client := testclient.New(t, buildApp())
    // /me is served by the Profile handler above.
    client.Get("/me").AssertStatus(http.StatusUnauthorized)

    client.Post("/login/", testclient.Body("application/x-www-form-urlencoded",
        strings.NewReader("username=rick&password=wubbalubbadubdub"))).
        AssertStatus(http.StatusOK)
    client.Get("/me").AssertStatus(http.StatusOK)

    client.Post("/logout/").AssertStatus(http.StatusOK)
    client.Get("/me").AssertStatus(http.StatusUnauthorized)
}

Where to go next

Cross-Origin Requests covers the protection sessions require, Authentication covers proving who a caller is in the first place, Dependencies covers the releases a session is written after, and Cookies covers cookies of your own.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.