@1auth/session

Session creation, signing, verification, and management with encrypted storage.

Install

npm i @1auth/session

Usage

import session from '@1auth/session'

session({
  store,
  notify,
  expire: 12 * 60 * 60, // absolute cap, the default (OWASP ASVS)
  idleExpire: 30 * 60, // idle timeout, the default (OWASP ASVS 7.3.1)
  limit: 10 // live sessions per account, the default
})

Configuration options

OptionTypeDefaultDescription
storeobjectrequiredStorage backend module
notifyobjectrequiredNotification module
tableobject—Table schema definition
encryptedFieldsstring[]["value"]Fields to encrypt
expirenumber43200 (12h)Absolute session lifetime in seconds, set at create and never extended — rotate() deliberately leaves it alone, so a session that keeps refreshing still ends. A row with no expire never resolves
idleExpirenumber1800 (30min)Idle timeout in seconds. lookup refuses a session whose update is further back than this, and moves update to now on a successful lookup, at most once a minute. Keep it well above 60, since a session can be up to a minute staler than it looks. A falsy value turns the idle timeout off
limitnumber10Live sessions one account may hold. At the maximum, create() expires the oldest live session and keeps the new one. A falsy value turns the cap off
idGenerateobject—ID generation config
randomIdobject—Random ID options (prefix: session_)
randomSessionIdobject—Session token options (prefix: sid_, entropy: 128)
encodefunction—Custom session encoding
decodefunction—Custom session decoding
checkMetadatafunction—Device metadata check function
notifyIdstring'authn-session'Prefix for the notify template id: {notifyId}-new-device

Concurrent sessions

An account holds at most limit live sessions, 10 by default. At the maximum, create() expires the oldest live session, by create time, and keeps the new one. It never refuses the new session: refusing it would let anyone who can reach the login form fill an account’s list and lock the owner out. The eviction is the same soft expire as expire(sub, id), so the row stays in the table and stops resolving, exactly as it does when a session reaches its own expire.

Only live sessions count toward the cap, so an expired row leaves room for a new session. The count is read, not locked, and no store here offers an atomic counter across an account’s rows, so two logins arriving at the same moment can both pass the check. Treat the cap as advisory.

The publicKey column

The session row carries a nullable publicKey, the device key a session is bound to. It is NULL for ordinary sessions and holds a public JWK for sessions opened through @1auth/session-dbsc. It is public by definition and compared byte for byte, so it is deliberately not in encryptedFields.

Two identifiers, two jobs

A session has an id and a sid, and confusing them is the easiest mistake to make here.

                    ┌──────────────────────────────────────┐
                    │            sessions row              │
                    │                                      │
  never sent to ───►│  id        session_xxx               │
  the client        │  sub       sub_xxx                   │
  (except as the    │  digest    <seasoned digest of sid>  │◄─── lookup finds
  DBSC session id)  │  value     <encrypted metadata>      │     the row by this
                    │  publicKey <device key, or NULL>     │
                    │  expire    create + 12h, absolute    │
                    └──────────────────────────────────────┘
                                     ▲
                                     │  the sid itself is never stored,
                                     │  only its digest -- a leaked table
                                     │  yields no usable session token
                                     │
  sid_xxx ───────────────────────────┘
  the secret. goes to the client, comes back on every request.

select, expire and remove take (sub, id) — they are for a user managing their own sessions, where sub is already known. lookup takes the sid and is the request path.

Client                    Your app                    @1auth/session
  │                          │                              │
  │  authenticated           │                              │
  ├─────────────────────────►│                              │
  │                          │  check(sub, value)           │
  │                          ├─────────────────────────────►│
  │                          │     metadata matches no      │
  │                          │     existing session?        │
  │                          │     notify -new-device       │
  │                          │  create(sub, value)          │
  │                          ├─────────────────────────────►│
  │                          │     mint sid, digest it,     │
  │                          │     encrypt metadata,        │
  │                          │     expire = now + 12h       │
  │  sid                     │◄─────────────────────────────┤
  │◄─────────────────────────┤                              │
  │                          │                              │
  │  request + sid           │                              │
  ├─────────────────────────►│                              │
  │                          │  lookup(sid, value)          │
  │                          ├─────────────────────────────►│
  │                          │     digest the sid, find     │
  │                          │     the row, reject if past  │
  │                          │     expire or idle, then     │
  │                          │     compare metadata         │
  │  200 or nothing          │◄─────────────────────────────┤
  │◄─────────────────────────┤                              │

lookup returns nothing rather than throwing when the metadata does not match, so a session whose device details have changed is simply not found. That check is a heuristic over client supplied headers — treat it as a signal, not a control. For a cryptographic device binding, see @1auth/session-dbsc.

What goes in value — and what must not

create(sub, value, values) takes two payloads and they behave very differently.

PurposeCompared on every lookup
valueThe device fingerprintYes, byte for byte
valuesExtra columns on the rowNo

checkMetadata compares the whole encoded value, so anything volatile in there ends a session the moment it changes:

// DON'T: every one of these ends the session on a routine, harmless change
session.create(sub, { os, browser, ip })
//                            │      └─ wifi → cellular logs the user out
//                            └─────── a browser auto-update logs them out
// DO: compare what identifies the device, store the rest alongside it
session.create(
  sub,
  { os, deviceType },                      // stable -> compared
  { metadata: JSON.stringify({ ip, browser, city }) }  // volatile -> stored only
)

Both end up on the row and both come back from list(sub), so your “your sessions” screen still shows the IP and browser. Only value decides whether a returning request is the same device.

Be clear-eyed about what this check is: every field in it comes from client-supplied headers, so it is a heuristic, not a control. It catches a cookie replayed from a different-looking client; it does not stop one replayed from a similar-looking one. For a real device binding, see @1auth/session-dbsc — and note that a bound session is still subject to this check, so the same advice applies.

API

randomId(options)

Generate a random session record ID.

randomSessionId(options)

Generate a random session token (the value stored in the cookie).

lookup(sid, value)

Find a session by its token. Returns nothing when the row is past expire, has no expire, has been idle longer than idleExpire, or its metadata does not match value.

A successful lookup counts as use: when update is at least a minute old it is moved to now, which costs one write a minute for an active session instead of one per request.

select(sub, id)

Get a specific session by subject and record ID.

list(sub)

List all sessions for a subject.

create(sub, value, values)

Create a new session. Generates a session token, encrypts metadata, and stores the record.

Pass a device public key as values.publicKey to open a bound session — @1auth/session-dbsc does this for you.

Returns: Object with id, sid (signed session token), and metadata

selectBinding(id)

Look a session up by record ID alone, with no sub. This is the read a DBSC refresh needs: it only receives the Sec-Session-Id header.

Returns a fixed, narrow projection — { id, sub, publicKey, create, expire } — and never value, digest or encryptionKey. Unlike sid, the record ID travels in a plaintext header, so this deliberately cannot hand back anything that would let the caller take the session over.

Returns: The binding, or undefined when no session has that ID. publicKey is empty for an unbound session.

rotate(sub, id, value, values)

Reissue sid on an existing row. id, publicKey, create and expire all survive, so the session keeps its identity and its absolute deadline while the credential the client holds changes.

Nothing in 1auth calls this — it exists for sliding sessions on the unbound path. A DBSC session gets credential turnover for free, because every refresh mints a new bound token. A session without DBSC has no such mechanism, so a sid stolen from it stays useful until the row expires. Calling rotate on activity closes that gap: issue the new sid as a fresh cookie and the stolen one stops resolving immediately.

Assumes the row exists. rotate writes without reading first, so the caller is expected to have one in hand. It deliberately does not extend expire — a session that keeps refreshing still ends on schedule.

check(sub, value)

Check if the device is new before session creation (for “new device” notifications). Only live sessions count as known devices. A device whose only session has expired is new again.

expire(sub, id)

Expire a session.

remove(sub, id)

Delete a session.

sign(sid)

Sign a session token with HMAC.

Returns: sid.signature

verify(sidWithSignature)

Verify a signed session token.

Returns: Original sid if valid, undefined otherwise

Post-quantum

Uses only CSPRNG session identifiers, HMAC-SHA3-384 cookie signatures, and symmetric field encryption, all post-quantum safe. No migration needed. See the full assessment in Post-quantum.