The Problem
My NBA Schedule Importer lets users sign in with Todoist and import their favorite team's schedule. When it was just for me, I hardcoded my own access token. Simple. But when I wanted to share it with others, I needed a way to handle tokens for multiple simultaneous users.
The core challenge: after OAuth, I get a Todoist access token. That token is sensitive—it grants full Todoist access. I need to:
- Store it securely after OAuth
- Retrieve it on the next request (to call the Todoist API)
- Keep each user's token separate from everyone else's
This is the session management problem. Every multi-user web app that handles auth needs to solve it.
What Is Session Management?
HTTP is stateless. Each request has no memory of the last. When User A signs in, gets their token, then makes a follow-up request, the server has no built-in way to know "this is the same User A from 30 seconds ago."
Sessions solve this by creating a persistent store that ties together a user's requests. The session is like a per-user folder: anything you put there is available on that user's next request.
Three Ways to Store Sessions
There are three common approaches, each with different tradeoffs:
- In-memory sessions: store session data in the server's RAM. Simple to set up, but wiped on every server restart and doesn't scale across multiple server instances (all requests from a user must hit the same server).
- Cookie-based sessions: store session data in a signed, encrypted cookie on the client. The server reads the cookie on each request. No server-side storage needed, scales across instances naturally, but limited to ~4KB.
- Database-backed sessions: store session data in a database (e.g. Redis, PostgreSQL). Good for apps needing persistence across server restarts, or for handling large session data.
The Winner: Cookie-based Sessions
For this app, cookie-based sessions made sense. Users sign in each time they use the app, so I don't need database persistence. And I'm only storing a small access token per user, so cookies (which have a ~4KB limit) are sufficient.
For Express.js, the standard library is
cookie-session. It provides key security features like httpOnly cookies
(preventing JavaScript access) and signed sessions
(preventing tampering)—exactly what I needed for secure
token storage.
The Production Code
0. Set up cookie-session
I configured cookie-session to meet the app's
security requirements:
const cookieSession = require('cookie-session');
app.use(cookieSession({
name: 'session',
keys: [process.env.SESSION_SECRET],
maxAge: 60 * 60 * 1000, // 1 hour
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'lax'
})); Breaking this down:
-
keys: secret key that signs session data, preventing users from tampering with their cookies -
maxAge: cookie expiration time (1 hour) -
httpOnly: prevents client-side JavaScript from accessing the cookie, blocking XSS attacks that try to steal tokens -
secure: only sends cookies over HTTPS (encrypted connections) in production -
sameSite: 'lax': allows cookies on normal navigation (like clicking a link to the app) but blocks them on cross-site requests (like forms submitted from other sites, known as CSRF attacks)
One additional layer: since cookie-session
doesn't encrypt the data, an attacker who steals the cookie
(e.g. via malware) could extract the plaintext token and
gain full access to the user's Todoist account. To prevent
this, I
encrypt the token
before storing it.
1. Store the Token
After OAuth, encrypt and store the
accessToken in req.session (the
per-user session object that
cookie-session provides):
// OAuth callback route
router.get('/oauth/callback', async (req, res) => {
const { access_token } = await exchangeCodeForToken(req.query.code);
// Encrypt and store token in secure, per-user session storage
req.session.accessToken = await encrypt(access_token);
...
}); 2. Use the Token
On every request, retrieve and decrypt the token:
// Handle form submission to start import
router.post("/import-schedule", async (req, res) => {
const { team } = req.body;
const accessToken = await decrypt(req.session.accessToken);
await createTodoistProject(team, accessToken);
...
}); That's it. The token is now stored securely per-user.
What I Learned
I could have researched session management upfront, but
building the prototype first helped me understand what I
actually needed from a session solution. This gave me the
context I needed to choose
cookie-session.
Before this project, sessions felt like something that
required deep security knowledge. But the core concept is
simple: give each user their own private storage.
cookie-session handles this while managing the
security details (signing, httpOnly, etc.). Adding
encryption on top provided an extra layer of defense.
Once I integrated it, the changes were smaller than I'd
worried: set up cookie-session, store encrypted
tokens in req.session, then decrypt and use
them.