Introduction
I'm Calvin, a backend engineering intern at HNG, currently wrapping up HNG14. This post is about two tasks from the internship that genuinely humbled me — one individual, one team. Not the tasks I completed smoothly. The ones that made me stare at logs at 2am wondering if I understood anything at all.
Task 1 (Individual)
— GitHub OAuth with PKCE for a CLI Tool
What it was
Stage 3 of HNG14 required building a full authentication system for Insighta Labs+, a demographic intelligence API. The requirement included GitHub OAuth, JWT access and refresh tokens, RBAC, and a Node.js CLI that could authenticate users through a local HTTP callback server.
The problem it was solving:
Users needed to authenticate with GitHub to access the API. The CLI had to open a browser, complete the OAuth flow, capture the callback on a local port, exchange the code for tokens, and hand control back to the terminal. The web portal needed the same flow on the server side.
How I approached it:
I implemented PKCE (Proof Key for Code Exchange) — the recommended approach for public clients like CLIs where you can't safely store a client secret. PKCE works by generating a random code_verifier, hashing it into a code_challenge, sending the challenge with the authorization request, then sending the original verifier during token exchange. GitHub verifies they match.
The flow looked straightforward on paper:
CLI generates verifier + challenge
→ Opens browser with challenge in state param
→ User authorizes on GitHub
→ GitHub redirects to localhost callback with code
→ CLI exchanges code + verifier for tokens
What broke:
GitHub truncates the state parameter.
I was storing the PKCE verifier inside the state param — a common pattern — and GitHub was silently cutting it off at a certain length. The verifier arriving at the callback was incomplete, the exchange failed, and I got a cryptic OAuth error with no indication of what was actually wrong.
I spent hours checking my PKCE implementation, my hash function, my base64 encoding. Everything was correct. The bug wasn't in my code — it was in my assumption that the state param would be passed through untouched.
How I fixed it:
I stopped trusting the state param for payload delivery. Instead, I stored the verifier in the database before redirecting, keyed by a short random state value. When the callback arrived, I used the state to look up the verifier from the DB, then completed the exchange. The state param became a lookup key, not a carrier.
typescript//
Before redirect
const state = randomBytes(16).toString('hex');
const verifier = generateVerifier();
await db.pkceVerifier.create({ data: { state, verifier } });
// In callback
const { verifier } = await db.pkceVerifier.findUnique({ where: { state } });
Deployment broke next:
Once OAuth worked locally, I deployed to Railway. The app crashed immediately. Wrong branch deployed first. Then the builder picked the wrong Node version — Prisma 7 requires Node 22+ and Railway was defaulting to Node 18. Then the Prisma engine binaries failed to download in the build environment.
I ended up switching to a Dockerfile approach, pinning Node 22, and explicitly setting PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 for the build step. Three separate deployment failures before it ran cleanly.
What I took away:
OAuth specifications leave more room for platform-specific behavior than the RFCs suggest. Test against the actual provider early — not just the spec. And deployment environments don't inherit your local assumptions. Pin everything explicitly.
Why I picked it
It was the first time I built a complete OAuth flow from first principles, including the CLI callback server. The PKCE bug was invisible in the code — it required understanding what GitHub does to your parameters, not just what OAuth says should happen. That distinction between spec and implementation is something I'll carry into every integration I build.
Task 2 (Team)
— JWT Refresh Token Rotation and the Argon2 Trap
What it was:
On the open-profile-be team project — a NestJS/TypeORM/PostgreSQL backend for a professional profile platform — I was assigned token and session management. The requirements included JWT refresh token rotation, per-device session tracking, and proper logout with token invalidation.
The problem it was solving:
The existing auth system issued tokens but didn't rotate them. If a refresh token was stolen, an attacker could use it indefinitely. The fix was rotation: every time a refresh token is used, invalidate it and issue a new one. Only the latest token in a chain is valid.
How I approached it:
I designed a sessions table with one row per device per user. Each row stored the hashed refresh token, a tokenId UUID, the device fingerprint, and expiry. On refresh, I'd verify the incoming token, invalidate the old session, and create a new one.
typescript//
On login
const tokenId = uuid();
const hashedToken = await argon2.hash(refreshToken);
await sessionRepo.save({ userId, tokenId, hashedToken, deviceId });
// On refresh
const session = await sessionRepo.findOne({ where: { hashedToken } });
What broke:
The findOne({ where: { hashedToken } }) always returned null.
Every time.
I checked the database directly — the row was there. I checked the token — it matched what I stored. I added logging everywhere. The hash in the DB and the hash I was querying with were both valid argon2 hashes of the same password. But they never matched as strings.
It took me an embarrassingly long time to realize what was happening.
Argon2 is non-deterministic by design. Every call to argon2.hash() produces a different output because it generates a new random salt each time. Hashing the same token twice gives two completely different strings. Storing a hash and then trying to find a row by that hash is fundamentally broken — you'll never find it because the stored hash and your query hash are different strings, even though both are valid hashes of the same value.
Argon2 is built for verification, not lookup:
typescript//
This is what argon2 is for
const isValid = await argon2.verify(storedHash, incomingToken); // ✅
// This is not what argon2 is for
const session = await repo.findOne({ where: { hashedToken } }); // ❌ always fails
How I fixed it
I added a tokenId UUID column. On login, I embed the tokenId in the JWT payload. On refresh, I extract the tokenId from the incoming token, look up the session directly by tokenId, then use argon2.verify() to confirm the token matches the stored hash.
typescript//
On login — embed tokenId in JWT
const tokenId = uuid();
const refreshToken = jwt.sign({ userId, tokenId }, secret);
const hashedToken = await argon2.hash(refreshToken);
await sessionRepo.save({ userId, tokenId, hashedToken });
// On refresh — lookup by tokenId, verify by hash
const { tokenId } = jwt.verify(incomingToken, secret);
const session = await sessionRepo.findOne({ where: { tokenId } });
const isValid = await argon2.verify(session.hashedToken, incomingToken);
What else broke
Cookie clearing on logout had mismatched attributes between development and production. A cookie set with SameSite=None; Secure in production couldn't be cleared with a response that omitted those attributes. The browser ignored the clear instruction silently.
CORS was also misconfigured — CORS_ORIGINS (plural) wasn't being read correctly, which caused preflight failures on the frontend that looked like auth failures.
Each of these was a separate 30-minute debugging session that felt like it should have taken 5 minutes.
What I took away:
Never use a cryptographic hash as a database lookup key. Hashes are for verification — one-way comparison, not identity. The lookup key should always be something deterministic: a UUID, a token ID, a fingerprint. The hash sits next to it purely for verification.
This feels obvious in retrospect. It isn't obvious when you're in the middle of it.
Why I picked it:
Because the bug was invisible. My code was logically correct. The argon2 calls were correct. The database writes were correct. The only problem was a conceptual misunderstanding about what a hash function is for. No linter catches that. No type error surfaces it. You have to understand the primitive deeply enough to know you're misusing it.
Closing:
Both bugs shared a pattern: the code looked right, the tests passed locally, and the failure came from a gap between what I thought a tool did and what it actually does.
GitHub's state param behavior isn't documented prominently. Argon2's non-determinism is documented, but it doesn't feel like a footgun until you use it wrong. The lesson isn't to read more docs — it's to build a habit of asking "what assumptions am I making about this library" before trusting it with critical logic.
That question has saved me more debugging time than anything else I've picked up this internship.
Calvin Iordye — Backend Engineering Intern| HNG14
Top comments (0)