← All posts
Security7 min readProject: Central auth service

Logging in through an ERP with a one-time nonce: authentication vs. authorization

Our users already have accounts in the company ERP, so the auth service doesn't manage passwords at all. Here is how a login is handed off to the ERP and back, why a short-lived, single-use nonce protects that round trip, why the ERP saying yes is not enough to get in, and how a pre-registered user list keeps control in the company's hands.

#Authentication#SSO#OAuth#Nonce#Web Security#Odoo

The problem

Users already authenticate in the company ERP; the auth service needed to reuse that identity safely without handling passwords or trusting every ERP account.

The result

A login round trip protected by a single-use, two-minute nonce, ERP identity as proof of who you are, and a separate, admin-controlled decision about what you may access.

Stack

NestJSOdooRedisJWTNode.js crypto

The problem

Every employee in our company already has an account in the company ERP (Odoo). It's where they log in every morning. The last thing we wanted was another password for our internal apps:

  • users would have to remember (and reset) another password,
  • the auth service would have to store password hashes, handle resets, lockouts, and everything that comes with them,
  • leaving the company would mean disabling accounts in two places.

So the decision was: the auth service never handles passwords. The ERP proves who the user is. The auth service decides what they may do.

That raises two security questions:

  1. How do we hand the login off to the ERP and safely come back, without someone faking the "I'm back, I'm logged in" step?
  2. The ERP has many accounts: employees, external accountants, test users, integrations. Does "has an ERP account" mean "may use our apps"? (No.)

The details

Authentication and authorization are different questions

  • Authentication: who are you? → answered by the ERP.
  • Authorization: what may you do, in which app? → answered by the auth service.

Keeping them separate shapes the whole flow. The ERP can say "this is Sara" and the auth service can still say "Sara isn't allowed into billing". Both answers are correct; they answer different questions.

The round trip

1. User clicks "Sign in"
2. Auth service creates a one-time nonce and remembers where to return
3. Browser is redirected to the ERP login, with a callback URL containing the nonce
4. User logs in at the ERP (or is already logged in)
5. ERP redirects back to the auth service's callback, with the nonce
6. Auth service checks and CONSUMES the nonce
7. The callback page asks the ERP "who is logged in?" (using the browser's ERP session)
8. The page posts that identity to the auth service, which creates the session

Why a nonce

Steps 3–5 leave our system and come back. Without a nonce, anyone could craft a request to the callback URL directly, or replay an old one. The nonce ties this return to this login attempt, started by our auth service, moments ago.

The challenges

  • The nonce must be unpredictable. If it can be guessed, it protects nothing.
  • It must be short-lived. A login link sitting in a browser history for a day shouldn't work.
  • It must be single-use. Replaying a captured callback must fail, even within the time window.
  • Expired nonces must not pile up. Abandoned logins must not leak memory.
  • The ERP saying "yes" must not be enough. An ERP account is not automatically an app account.
  • Errors must be understandable. "Login link expired" is fine; a stack trace is not.

The solution

1. Create the nonce

import { randomBytes } from 'crypto';

const NONCE_TTL_MS = 2 * 60 * 1000;          // valid for 2 minutes
const NONCE_SWEEP_INTERVAL_MS = 5 * 60 * 1000;

startLogin(request: Request, response: Response, returnTo?: string, app?: string) {
  const nonce = randomBytes(32).toString('hex');  // 256 bits from a CSPRNG
  this.nonces.set(nonce, { createdAt: Date.now(), returnTo: returnTo ?? '/', app });

  const callbackUrl = `${publicBaseUrl(request)}/auth/callback?nonce=${nonce}`;
  response.redirect(302, `${ERP_LOGIN_URL}?returnTo=${encodeURIComponent(callbackUrl)}`);
}
  • 32 random bytes from Node's cryptographic random generator: not guessable.
  • The nonce remembers where to send the user afterwards (returnTo) and which app they were logging into, so the return trip doesn't need to trust anything from the URL except the nonce itself.

2. Consume it: check and delete in one step

/** Validates and deletes a nonce in one step; returns null if unknown or expired. */
consume(nonce: string): NonceEntry | null {
  const entry = this.nonces.get(nonce);
  if (!entry) return null;
  this.nonces.delete(nonce);                         // single use, always
  if (Date.now() - entry.createdAt > NONCE_TTL_MS) return null;
  return entry;
}

The nonce is deleted the moment it's looked up, before even checking its age. Valid or not, it can never be used twice. A replayed callback finds nothing.

3. Sweep abandoned nonces

Users close the tab halfway through logins all the time. A periodic sweep removes expired entries, and the timer is cleared when the module shuts down:

private readonly sweep = setInterval(() => this.removeExpired(), NONCE_SWEEP_INTERVAL_MS);
onModuleDestroy() { clearInterval(this.sweep); }

4. A friendly callback

If the nonce is missing, unknown, used or expired, the callback returns a small "Login link expired. Please try signing in again." page with a 400, not a technical error.

If it's valid, the callback returns a small page that asks the ERP who is logged in (using the browser's ERP session, with credentials: 'include') and posts that identity, with the app name, to the auth service's session endpoint. Then it sends the user back to where they started.

5. The ERP says who; the auth service decides whether

When the identity arrives, the auth service does not create users:

const user = await users.findByEmail(identity.login);
if (!user) {
  throw new UnauthorizedException('Access denied. User is not registered in the system.');
}
if (user.status === 'DISABLED') {
  throw new UnauthorizedException('Access denied. This account has been disabled.');
}

Users are registered by an admin, never auto-created on first login. A valid ERP account that isn't on the list gets a clear refusal. That keeps control of who can use the apps inside the company's access management, not in whoever happens to have an ERP account.

Then come the authorization checks for the requested app (is it active? is the user's group activated for it?), described in "One login, many apps" and "Groups by department". A user can pass authentication perfectly and still be told "you don't have access to this app". That's not an error in the login; it's the system working.

6. Never two identities for one user

The token keeps two ids on purpose:

  • uid: the ERP's id, used only to line the person up with the ERP,
  • userId: the auth service's own row id, the one every Redis counter (token version, permissions version) is keyed on.

Mixing them up would mean reading a Redis key that was never written, getting the default value, and rejecting perfectly valid sessions. Documenting "never use uid to read Redis" right on the type saved us from a subtle class of bugs.

7. Development login, without the ERP

Developers need to log in locally without the real ERP. A separate development login creates the same session and the same tokens, with the same checks (registered user, active app, group access), so local behavior matches production. It's a separate route, clearly apart from the real login flow, and a route like this should always be locked down outside development.

Lessons learned

  • Don't own passwords you don't need. Let the system that already authenticates users keep doing it.
  • Authentication and authorization are separate decisions. "The ERP knows you" and "you may use this app" are different questions with different owners.
  • Protect every round trip that leaves your system. A nonce ties the return to the request that started it.
  • Nonces must be random, short-lived and single-use. Delete on lookup, before any other check.
  • Clean up abandoned state. Sweep expired nonces, and clear timers on shutdown.
  • Don't auto-create users from an external identity. Keep the list of who may enter under your admins' control.
  • Name your identifiers clearly. An external id and an internal id in the same token need clear rules about which one to use where.
  • Errors should help the user retry. "Login link expired" is a complete instruction.

FAQ

What is a nonce in a login flow?

A random, single-use value created when a login starts and checked when the user returns. It proves the return belongs to a login your system actually started, and prevents replaying or forging the callback.

How long should a login nonce be valid?

Short, usually a few minutes. It only needs to survive the round trip to the identity provider and back. Two to five minutes is typical.

Should an SSO login create user accounts automatically?

Not when access must be controlled. Auto-creating accounts means anyone with an identity at the provider gets in. Requiring pre-registered users keeps the decision with your administrators.

What's the difference between authentication and authorization?

Authentication confirms who someone is (for example through a login). Authorization decides what that person is allowed to do. A user can be authenticated and still not authorized for a specific application or action.

Facing a similar problem?

I help teams fix performance, backend and reliability issues in production.

Let's talk
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us