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:
- 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.
- 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:
- 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.
- 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.