The end-to-end recipe for a customer-facing volunteer portal — letting volunteers see their own data without VOMO admin access. Authentication architecture, what to expose, security boundaries, and the partner-side patterns that make it work.
A common partner product: a volunteer self-service portal — a customer-facing web app where individual volunteers can see their own data (participations, hours, certificates, profile, upcoming shifts) without needing access to the VOMO admin UI. The customer wants the simplicity of “volunteers manage their own profile” without giving volunteers admin permissions; you build the experience by acting as the trusted intermediary.This recipe is different from the others — it’s not about data flow into or out of VOMO. It’s about product shape: how to architect a portal that lets each volunteer see only their own data, securely, while still using just one VOMO Bearer token (the partner’s). The architecture is what makes this work.
VOMO doesn’t have per-volunteer API tokens. The Bearer token belongs to the customer’s organization — it has full read access to all volunteers, projects, groups, etc. for that customer.This is the opposite of what a portal needs: each volunteer should see only their own data. The partner integration is the bridge — it authenticates the volunteer (using identity outside VOMO), then uses its admin-level VOMO token to fetch only that volunteer’s data and serve it back.The security boundary is the partner backend. It’s the only thing that holds the VOMO token; the volunteer’s browser never sees it.
The customer already has an identity system (Active Directory, Okta, Google Workspace, etc.). Volunteers log in with their corporate identity; the portal extracts the verified email; the partner maps email to VOMO User ID.
JavaScript
async function resolveVomoUserFromSession(session, customerId) { // 1. Trust the email from the authenticated session const email = session.user.email; // SSO-verified if (!email) throw new Error('No verified email in session'); // 2. Look up the VOMO user by email const vomoToken = await credentials.getVomoToken(customerId); const params = new URLSearchParams({ email_like: email }); const response = await fetch( `https://api.vomo.org/v1/users?${params}`, { headers: { Authorization: `Bearer ${vomoToken}` } } ); const result = await response.json(); const exactMatch = result.data.find( (u) => u.email?.toLowerCase() === email.toLowerCase() ); if (!exactMatch) { throw new NotFoundError(`No VOMO user for ${email}`); } return exactMatch.id;}
Pros: Customer’s existing identity is reused; no separate volunteer accounts to manage. Cons: Requires SSO integration; the email mapping breaks if a volunteer’s email changes in either system.
The portal has its own login (email + password, magic link, etc.); volunteers register with the email they use in VOMO; the portal maps the registered email to VOMO User ID.
JavaScript
async function registerVolunteer(email, password) { // 1. Verify the email exists in VOMO (otherwise the registration is meaningless) const vomoUser = await lookupVomoUserByEmail(email); if (!vomoUser) { throw new ValidationError('Email not registered as a volunteer'); } // 2. Create the partner-side account const partnerAccount = await partnerAuth.createAccount({ email, password, verifiedEmail: false, }); // 3. Send verification email await emailService.sendVerification(partnerAccount.id, email); // 4. Store the mapping await db.upsert('portal_accounts', { partner_account_id: partnerAccount.id, vomo_user_id: vomoUser.id, email: email.toLowerCase(), verified: false, });}
Pros: No SSO dependency; works for customers without existing identity systems. Cons: Volunteer must register; password recovery and verification are now your problem.
A simpler partner-managed flow — no passwords, just emailed magic links:
JavaScript
async function startLogin(email) { // 1. Verify VOMO existence const vomoUser = await lookupVomoUserByEmail(email); if (!vomoUser) { // Return success even if user doesn't exist — don't reveal whether the email is registered return { sent: true }; } // 2. Generate a single-use token const token = generateSecureToken(); await db.insert('login_tokens', { token, vomo_user_id: vomoUser.id, email: email.toLowerCase(), expires_at: new Date(Date.now() + 15 * 60 * 1000), // 15 min used: false, }); // 3. Email the link await emailService.send({ to: email, subject: 'Your volunteer portal sign-in link', body: `Click to sign in: https://portal.example.com/auth?t=${token}`, }); return { sent: true };}async function completeLogin(token) { const record = await db.getLoginToken(token); if (!record || record.used || record.expires_at < new Date()) { throw new AuthError('Invalid or expired link'); } await db.markLoginTokenUsed(token); // Issue a session return sessionManager.createSession({ vomo_user_id: record.vomo_user_id, email: record.email, });}
Pros: No password management; no SSO required; low-friction for volunteers. Cons: Email deliverability matters; users without email access can’t log in.Most partner portals use Option A (when customer has SSO) or Option C (for broader reach). Option B requires the most operational work.
The cardinal rule: the partner backend is the only thing that holds the VOMO token. The frontend (browser) must not have it; the volunteer must not have it; nothing untrusted gets the token.The backend exposes endpoints scoped to the authenticated volunteer’s identity:The portal API (/portal/api/me) returns only the authenticated volunteer’s data. The backend resolves the session to a VOMO User ID, then queries VOMO for that specific user — never for “all users.”
// Portal backend endpoint: GET /portal/api/meapp.get('/portal/api/me', requireAuthenticatedSession, async (req, res) => { const session = req.session; const customerId = session.customer_id; const vomoUserId = session.vomo_user_id; // The session ties the request to a specific VOMO User ID // Resolved at login time; trusted throughout the session lifetime try { const vomoToken = await credentials.getVomoToken(customerId); const response = await fetch( `https://api.vomo.org/v1/users/${vomoUserId}`, { headers: { Authorization: `Bearer ${vomoToken}`, Accept: 'application/json', }, } ); if (!response.ok) { return res.status(response.status).json({ error: 'Failed to load profile' }); } const data = (await response.json()).data; // Return ONLY the fields the volunteer should see — don't blindly forward res.json({ id: data.id, first_name: data.first_name, last_name: data.last_name, email: data.email, phone: data.phone, address: data.address, birthday: data.birthday, // ... selected safe fields ... }); } catch (err) { res.status(500).json({ error: 'Internal error' }); }});
The endpoint doesn’t take a user ID parameter from the request — the ID comes from the authenticated session. A volunteer can’t request /portal/api/me?userId=999 and get someone else’s data; the parameter is ignored if present, and the session’s ID is always used.
// ❌ Anti-pattern — taking the ID from the requestapp.get('/portal/api/user/:id', requireAuthenticatedSession, async (req, res) => { const userId = req.params.id; // Whatever the client passes in // ... fetch and return that user's data ...});
This is an authorization vulnerability — any logged-in volunteer can fetch any other volunteer’s data by changing the URL. The frontend has no business specifying which user’s data to load; the session implicitly knows.
Even with session-scoped IDs, defend against attacks that might somehow bypass the session check:
JavaScript
async function loadVolunteerData(session, requestedAction) { // 1. Re-verify the session is valid every request if (!session?.vomo_user_id) throw new AuthError('No session'); // 2. Re-verify the VOMO user still exists and matches the session const vomoUser = await fetchVomoUserDetail(session.customer_id, session.vomo_user_id); if (vomoUser.email?.toLowerCase() !== session.email?.toLowerCase()) { // Session email and VOMO email no longer match — log out await sessionManager.invalidate(session.id); throw new AuthError('Session invalidated due to identity change'); } // 3. Now safe to proceed return doAction(requestedAction, vomoUser);}
Re-verifying the email match catches the case where a volunteer’s email was changed in VOMO since they logged in — the session may still be valid by token but no longer match reality.
For the “other volunteers at the same Date” case: while GET /projects/date/{id} returns the full participant list, the portal should filter to show only the logged-in volunteer’s own entry.
Whitelisting is safer than blacklisting — explicitly list what to expose, and anything new in VOMO’s response (e.g., a future-added field) defaults to “not exposed” until you’ve decided.
The email-change problem (from the upsert workflow) means a volunteer changing their email via the portal would either:
Create a new VOMO user (because upsert matches by email)
Orphan the existing record
Block email changes at the portal level. Direct volunteers to the customer’s admin team for email updates — they have access to the admin UI’s merge tooling.
A portal page might show: profile, recent participations, hour totals, upcoming shifts, certificates. That’s potentially 5+ VOMO API calls per page load. Without caching, the portal will be slow and rate-limit-bound.
A 60-second TTL is reasonable for portal use — fresh enough that “I just signed up for a shift” appears within a minute, while reducing API calls dramatically.Invalidate on writes: after a profile update, invalidate the user’s profile cache.
Sometimes a logged-in user has no corresponding VOMO record:
They were deleted in VOMO (but their portal account remains)
Their email changed in VOMO
They never had a VOMO record (registered for the portal but aren’t a volunteer)
JavaScript
async function ensureVomoUserAccessible(session) { const user = await fetchVomoUserDetail(session.customer_id, session.vomo_user_id); if (!user) { // VOMO user no longer exists or isn't accessible await db.markPortalAccountOrphaned(session.partner_account_id); throw new ResourceGoneError('No VOMO record for this account'); } if (user.email?.toLowerCase() !== session.email?.toLowerCase()) { // Email mismatch — sessionEmail and VOMO email differ await db.markPortalAccountStale(session.partner_account_id); throw new AuthError('Account email no longer matches; please contact support'); } return user;}
Surface a clear “account needs reconciliation” UI for these cases — not a generic error.
Step 7: things volunteers might want that aren’t possible
A portal naturally raises product expectations. Some common requests don’t have API support:
Volunteer request
Why it doesn’t work
”Sign me up for this shift”
Participation writes aren’t exposed in the API
”Cancel my signup”
Same
”Mark me as checked in”
Same
”Award me this certificate”
Certificate write isn’t exposed
”Update my email”
Email-change problem — admin UI only
”Delete my account / data”
User deletion isn’t exposed; partner can mark deleted in portal-side data only
Set these expectations clearly in the portal UI. For shift signup specifically, link out to the VOMO admin UI — https://portal.vomo.org/projects/{slug} is typically the user-facing signup path.See Understand Write Limitations for the full picture.
The portal-to-VOMO mapping depends on email. If the volunteer changes their email in the customer’s identity provider (SSO) but not in VOMO, the next login fails to find the VOMO user. Build the “email mismatch” alert path; ideally proactively detect and handle.
The portal frontend shouldn’t include vomo_user_id in URLs or any client-visible state. Internal references should be the partner’s own person ID (or session-scoped).
Some customers store sensitive data in custom profile fields (background-check status, accommodations, etc.). Default to hiding profile fields unless explicitly whitelisted; let the customer configure which fields the portal exposes.
Every page load is N API requests. For high-traffic customers (e.g., a corporate volunteer program with thousands of weekly portal visits), the portal can easily become the dominant rate-budget consumer. Cache aggressively, batch when possible, and consider per-customer rate budgets — see Rate Limits and API Performance Tips.
Standard web security practices apply — HTTPS only, secure cookies, CSRF protection, session timeouts, etc. The novel concern for this product is the “session VOMO User ID is stale” case; build re-verification into session refresh.
Volunteers whose VOMO record has gone inactive (no recent participations, possibly archived) may still log into the portal and see a confusing empty state. Build a friendly “you haven’t volunteered with us recently — here’s how to get involved again” experience for low-activity users.
✅ Identity flow connecting external auth to VOMO User IDs
✅ Backend API exposing per-volunteer scoped endpoints
✅ Strict authorization — session implicit, no client-supplied IDs
✅ Whitelisted field exposure with friendly labels
✅ Profile update flow with email-change protection
✅ Caching layer for portal performance
✅ Handling for the orphaned-account and email-mismatch cases
✅ Clear product expectations for what the API doesn’t allow
This is a product-shape recipe — the architecture matters more than any single piece of code. The patterns transfer to any partner-built customer-facing experience on top of the Volunteer API.