← Back to Home

From Personal Tool to Public App: Learning Session Management

I built a tool that imports NBA schedules into Todoist. It started as a Python script to import Celtics games for myself.

When I decided to make it public, I knew I'd need OAuth authentication so users could import games into their own Todoist accounts.

But I'd never managed user credentials in a production app before. I wanted to make sure I handled users' Todoist access tokens securely. It didn't add much complexity, as this post will demonstrate. You can try the final app here or watch a 1-minute demo.

The Prototype

I didn't want my uncertainty about credential management to block progress on everything else. So I built a local prototype with all the core functionality, knowing I'd revisit secure credential storage later. Here's how I handled OAuth access tokens initially:

1. Store the Token

After OAuth, I stored the access token in a global variable:

// Global variable to store OAuth token
let accessToken;

// OAuth callback: store token in global scope
router.get("/oauth/callback", async (req, res) => {
    const { access_token } = await exchangeCodeForToken(req.query.code);
    accessToken = access_token;
    ...
});

2. Use the Token

I used the global access token on every request, such as this one:

// Handle form submission to start import
router.post("/import-schedule", async (req, res) => {
    const { team } = req.body;
    await createTodoistProject(team, accessToken);
    ...
});

This worked fine when only I was using it. But with multiple users, the accessToken variable is shared across all requests. So if User A authenticates, then User B authenticates moments later, User B's token overwrites User A's. Now User A is making API calls with User B's credentials!

What's Missing for Production

To support multiple concurrent users, I needed secure, per-user storage of Todoist access tokens.

Decision 1: Client-side or Server-side?

First, I needed to decide where to store tokens:

  1. Client-side storage: store tokens directly in the user's browser via localStorage. This is vulnerable to XSS attacks (Cross-Site Scripting) where malicious JavaScript could steal the token. Not suitable for sensitive data.
  2. Server-side storage: store tokens on the server in sessions, i.e. per-user storage that persists across requests. Much more secure, because tokens never leave the server.

Decision 2: Which Session Implementation?

Once I knew I needed sessions, I had to choose how to implement them:

  1. Cookie-based sessions: store session data in a cookie (a small piece of data browsers send with every request). Lightweight, secure, no external infrastructure needed.
  2. 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:

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.

← Back to Home