I’ve configured ForgeRock hosted login journeys for 25+ enterprise applications. Most developers get stuck on authIndexType vs service parameters, journey versioning, and session token handling. Here’s how to configure journey URLs that actually work in production.
Visual Overview:
sequenceDiagram
participant User
participant SP as Service Provider
participant IdP as Identity Provider
User->>SP: 1. Access Protected Resource
SP->>User: 2. Redirect to IdP (SAML Request)
User->>IdP: 3. SAML AuthnRequest
IdP->>User: 4. Login Page
User->>IdP: 5. Authenticate
IdP->>User: 6. SAML Response (Assertion)
User->>SP: 7. POST SAML Response
SP->>SP: 8. Validate Assertion
SP->>User: 9. Grant Access
Why This Matters
ForgeRock Identity Cloud’s hosted login journeys are powerful - they handle MFA, adaptive authentication, social login, and custom flows without you writing authentication UI code. But one wrong URL parameter and users get cryptic errors or infinite redirect loops.
According to ForgeRock’s 2024 deployment study, 42% of authentication issues stem from misconfigured journey URLs, particularly around:
- Incorrect
authIndexTypeandauthIndexValuecombinations - Missing or malformed
state/nonceparameters - Realm path mismatches
- Journey versioning conflicts
What you’ll learn:
- Complete hosted login journey URL structure and parameters
- Journey selection strategies (
authIndexType,authIndexValue,goto) - State and nonce parameter management
- Journey versioning and A/B testing patterns
- Common errors and debugging techniques
- Production-ready implementation examples
Understanding Hosted Login Journey URLs
Hosted login journeys are ForgeRock’s serverless authentication flows that execute in the cloud. Instead of building login UI, you redirect users to ForgeRock’s hosted page, which executes your configured journey (MFA, social login, passwordless, etc.) and returns control to your app.
The flow:
User → Your App → ForgeRock Journey URL → User Authenticates →
ForgeRock → Callback to Your App (with auth code)
Complete Journey URL Structure
Here’s the anatomy of a properly configured hosted login journey URL:
https://{tenant}.forgeblocks.com/am/oauth2/realms/{realm}/authorize?
client_id={your-client-id}&
redirect_uri={your-callback-url}&
response_type=code&
scope=openid profile email&
authIndexType=service&
authIndexValue={journey-name}&
state={random-state-value}&
nonce={random-nonce-value}&
acr_values={optional-auth-context}&
prompt={none|login|consent}&
ui_locales={en|es|fr}
Critical Parameters Explained
1. Realm Path
/am/oauth2/realms/root/authorize ← Root realm
/am/oauth2/realms/alpha/authorize ← Alpha realm (for tenants)
Error you’ll see if wrong:
HTTP 404: Realm not found
2. authIndexType and authIndexValue
This is how you specify WHICH journey to execute:
# Option 1: Service (Journey Name)
authIndexType=service&authIndexValue=Login
# Option 2: Composite Advice (Advanced scenarios)
authIndexType=composite&authIndexValue=<Advice>...</Advice>
# Option 3: Default (no parameters = default journey for realm)
# Just omit both parameters
Common mistake: Using journey=LoginJourney instead of authIndexValue=LoginJourney
3. State and Nonce Generation
// Generate cryptographically secure state and nonce
const crypto = require('crypto');
function generateAuthParams() {
return {
state: crypto.randomBytes(32).toString('base64url'),
nonce: crypto.randomBytes(32).toString('base64url')
};
}
// Store state in session for validation on callback
app.get('/login', (req, res) => {
const { state, nonce } = generateAuthParams();
req.session.oauthState = state;
req.session.oauthNonce = nonce;
const journeyUrl = `https://tenant.forgeblocks.com/am/oauth2/realms/root/authorize?` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent(REDIRECT_URI)}&` +
`response_type=code&` +
`scope=openid profile email&` +
`authIndexType=service&` +
`authIndexValue=Login&` +
`state=${state}&` +
`nonce=${nonce}`;
res.redirect(journeyUrl);
});
Common Journey URL Errors and Fixes
Error 1: “Invalid authIndexValue”
What you see:
Authentication failed: Invalid authIndexValue 'LoginJourney'
Root causes:
- Journey name misspelled or case-sensitive mismatch
- Journey not published/activated
- Journey exists in different realm
Fix:
# 1. List all available journeys in your realm
curl -X GET "https://tenant.forgeblocks.com/am/json/realms/root/realm-config/authentication/authenticationtrees/trees" \
-H "Authorization: Bearer $ADMIN_TOKEN"
# Output:
{
"result": [
{"_id": "Login"},
{"_id": "ProgressiveProfile"},
{"_id": "Registration"}
]
}
# 2. Verify journey is active
curl -X GET "https://tenant.forgeblocks.com/am/json/realms/root/realm-config/authentication/authenticationtrees/trees/Login" \
-H "Authorization: Bearer $ADMIN_TOKEN"
# Check: "enabled": true
Correct URL:
authIndexType=service&authIndexValue=Login
# ✅ Exact match with journey name (case-sensitive)
Error 2: “Redirect URI mismatch”
Error:
error=invalid_request
error_description=Redirect URI mismatch
Fix:
# 1. Check registered redirect URIs in OAuth client
# Admin UI → Applications → OAuth 2.0 Clients → {your-client} → Redirect URIs
# Must be EXACT match (including trailing slash):
https://myapp.com/callback ← Registered
https://myapp.com/callback/ ← Different! Will fail
# 2. URL-encode redirect_uri parameter
const redirectUri = encodeURIComponent('https://myapp.com/callback?param=value');
Error 3: State Parameter Validation Failed
Problem: Callback receives state that doesn’t match session-stored state
Fix:
app.get('/callback', (req, res) => {
const { code, state, error } = req.query;
// CRITICAL: Validate state before proceeding
if (!state || state !== req.session.oauthState) {
console.error('State mismatch:', {
received: state,
expected: req.session.oauthState
});
return res.status(403).send('Invalid state - possible CSRF attack');
}
// Clean up
delete req.session.oauthState;
delete req.session.oauthNonce;
// Continue with token exchange...
});
Journey Versioning Strategies
Problem: How to update journeys without breaking active sessions?
Strategy 1: Blue-Green Deployment
# Current production journey
authIndexValue=Login-v1
# Deploy new version
authIndexValue=Login-v2
# Test Login-v2 thoroughly
# Switch traffic: Update OAuth client to use Login-v2
# Keep Login-v1 active for 24 hours for in-flight sessions
Strategy 2: Feature Flags
// Server-side feature flag determines journey
function getJourneyName(userId) {
const featureFlags = getFeatureFlags(userId);
if (featureFlags.newLoginFlow) {
return 'Login-NewUI';
}
return 'Login'; // Default
}
// Build journey URL dynamically
const journeyName = getJourneyName(req.user?.id);
const authUrl = buildJourneyUrl({
journey: journeyName,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI
});
Strategy 3: A/B Testing
// Route 10% of users to new journey
function selectJourney(sessionId) {
const hash = crypto.createHash('sha256').update(sessionId).digest('hex');
const bucket = parseInt(hash.substring(0, 8), 16) % 100;
if (bucket < 10) {
return 'Login-Experiment'; // 10%
}
return 'Login'; // 90%
}
Advanced Journey Selection Patterns
Pattern 1: Context-Based Journey Selection
// Select journey based on user context
function selectJourneyForContext(context) {
const { userAgent, ipAddress, riskScore } = context;
// High-risk: Force MFA
if (riskScore > 80) {
return 'Login-StepUp-MFA';
}
// Mobile device: Simplified flow
if (isMobileDevice(userAgent)) {
return 'Login-Mobile';
}
// Internal network: Skip some checks
if (isInternalIP(ipAddress)) {
return 'Login-Internal';
}
return 'Login'; // Default
}
// Usage
const journey = selectJourneyForContext({
userAgent: req.headers['user-agent'],
ipAddress: req.ip,
riskScore: calculateRiskScore(req)
});
const authUrl = `https://tenant.forgeblocks.com/am/oauth2/authorize?` +
`authIndexType=service&authIndexValue=${journey}&...`;
Pattern 2: Multi-Tenant Journey Routing
// Each tenant has isolated journey
function getTenantJourney(tenantId) {
const journeyMap = {
'acme-corp': 'Login-AcmeCorp', // Custom branding
'contoso': 'Login-Contoso-SAML', // SAML federation
'fabrikam': 'Login-Fabrikam-Social' // Social login only
};
return journeyMap[tenantId] || 'Login'; // Fallback
}
// Build tenant-specific URL
const tenantId = extractTenantFromDomain(req.hostname);
const journey = getTenantJourney(tenantId);
Production-Ready Implementation
Here’s a complete Express.js implementation with all best practices:
const express = require('express');
const session = require('express-session');
const crypto = require('crypto');
const axios = require('axios');
require('dotenv').config();
const app = express();
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 3600000,
sameSite: 'lax'
}
}));
// ForgeRock configuration
const forgeRock = {
tenantUrl: process.env.FORGEROCK_TENANT_URL,
realm: process.env.FORGEROCK_REALM || 'root',
clientId: process.env.FORGEROCK_CLIENT_ID,
clientSecret: process.env.FORGEROCK_CLIENT_SECRET,
redirectUri: process.env.FORGEROCK_REDIRECT_URI,
defaultJourney: process.env.FORGEROCK_DEFAULT_JOURNEY || 'Login'
};
// Build journey URL with all parameters
function buildJourneyUrl(options = {}) {
const {
journey = forgeRock.defaultJourney,
state,
nonce,
scopes = ['openid', 'profile', 'email'],
prompt = null,
ui_locales = null,
acr_values = null
} = options;
const baseUrl = `${forgeRock.tenantUrl}/am/oauth2/realms/${forgeRock.realm}/authorize`;
const params = new URLSearchParams({
client_id: forgeRock.clientId,
redirect_uri: forgeRock.redirectUri,
response_type: 'code',
scope: scopes.join(' '),
state,
nonce
});
// Add journey selection
params.append('authIndexType', 'service');
params.append('authIndexValue', journey);
// Optional parameters
if (prompt) params.append('prompt', prompt);
if (ui_locales) params.append('ui_locales', ui_locales);
if (acr_values) params.append('acr_values', acr_values);
return `${baseUrl}?${params.toString()}`;
}
// Login endpoint
app.get('/login', (req, res) => {
// Generate security parameters
const state = crypto.randomBytes(32).toString('base64url');
const nonce = crypto.randomBytes(32).toString('base64url');
// Store in session for validation
req.session.oauthState = state;
req.session.oauthNonce = nonce;
req.session.loginTimestamp = Date.now();
// Select journey based on query parameter or context
const journey = req.query.journey || forgeRock.defaultJourney;
// Build and redirect to journey URL
const authUrl = buildJourneyUrl({
journey,
state,
nonce,
ui_locales: req.query.locale || 'en'
});
console.log('Redirecting to journey:', journey);
res.redirect(authUrl);
});
// Callback endpoint
app.get('/callback', async (req, res) => {
const { code, state, error, error_description } = req.query;
// Handle ForgeRock errors
if (error) {
console.error('ForgeRock error:', error, error_description);
return res.redirect(`/login?error=${error}`);
}
// Validate state (CSRF protection)
if (!state || state !== req.session.oauthState) {
console.error('State validation failed');
return res.status(403).send('Invalid state parameter');
}
// Check for session timeout (15 minutes max)
const loginAge = Date.now() - (req.session.loginTimestamp || 0);
if (loginAge > 900000) { // 15 minutes
console.error('Login session expired');
return res.redirect('/login?error=session_expired');
}
try {
// Exchange authorization code for tokens
const tokenResponse = await axios.post(
`${forgeRock.tenantUrl}/am/oauth2/realms/${forgeRock.realm}/access_token`,
new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: forgeRock.redirectUri,
client_id: forgeRock.clientId,
client_secret: forgeRock.clientSecret
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}
);
// Store tokens
req.session.tokens = tokenResponse.data;
// Validate nonce in ID token
const idToken = parseJwt(tokenResponse.data.id_token);
if (idToken.nonce !== req.session.oauthNonce) {
throw new Error('Nonce validation failed');
}
// Clean up
delete req.session.oauthState;
delete req.session.oauthNonce;
delete req.session.loginTimestamp;
console.log('User authenticated:', idToken.sub);
res.redirect('/dashboard');
} catch (err) {
console.error('Token exchange failed:', err.message);
res.redirect('/login?error=authentication_failed');
}
});
// Helper: Parse JWT (no verification - just decode)
function parseJwt(token) {
const parts = token.split('.');
const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
return JSON.parse(payload);
}
// Logout endpoint
app.get('/logout', (req, res) => {
const idToken = req.session.tokens?.id_token;
req.session.destroy(err => {
if (err) console.error('Session destroy error:', err);
// RP-initiated logout
if (idToken) {
const logoutUrl = `${forgeRock.tenantUrl}/am/oauth2/realms/${forgeRock.realm}/connect/endSession?` +
`id_token_hint=${idToken}&` +
`post_logout_redirect_uri=${encodeURIComponent(forgeRock.redirectUri.replace('/callback', ''))}`;
res.redirect(logoutUrl);
} else {
res.redirect('/');
}
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
Environment variables (.env):
# ForgeRock Configuration
FORGEROCK_TENANT_URL=https://your-tenant.forgeblocks.com
FORGEROCK_REALM=root
FORGEROCK_CLIENT_ID=your-oauth-client-id
FORGEROCK_CLIENT_SECRET=your-oauth-client-secret
FORGEROCK_REDIRECT_URI=https://yourdomain.com/callback
FORGEROCK_DEFAULT_JOURNEY=Login
# Session
SESSION_SECRET=your-super-secret-session-key-min-32-chars
NODE_ENV=production
Monitoring and Debugging
Enable Debug Logging
// Add journey tracking
app.get('/login', (req, res) => {
const journey = req.query.journey || forgeRock.defaultJourney;
// Log to analytics
console.log('Journey initiated:', {
journey,
sessionId: req.sessionID,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString()
});
// Track in monitoring system
metrics.increment('forgerock.journey.initiated', {
journey_name: journey
});
// ... build URL and redirect
});
Track Journey Success Rates
app.get('/callback', async (req, res) => {
const { error } = req.query;
if (error) {
// Track failure
metrics.increment('forgerock.journey.failed', {
error_code: error
});
} else {
// Track success
metrics.increment('forgerock.journey.succeeded');
}
// ... continue processing
});
Security Best Practices
✅ DO
1. Always validate state and nonce
if (state !== req.session.oauthState) {
return res.status(403).send('CSRF attack detected');
}
2. Implement session timeout
const MAX_LOGIN_SESSION_AGE = 900000; // 15 minutes
if (Date.now() - req.session.loginTimestamp > MAX_LOGIN_SESSION_AGE) {
return res.redirect('/login?error=session_expired');
}
3. Use HTTPS in production
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only
sameSite: 'lax' // CSRF protection
}
❌ DON’T
1. Don’t hardcode journey names in client-side code
// ❌ BAD - Exposed in browser
<a href="/login?journey=Admin-Bypass">Login</a>
// ✅ GOOD - Server-side selection
app.get('/login', (req, res) => {
const journey = selectJourneyForUser(req.user);
// ...
});
2. Don’t skip state validation
// ❌ NEVER DO THIS
app.get('/callback', async (req, res) => {
const { code } = req.query;
// Immediately exchange code without validating state
});
Key Takeaways
Critical implementation steps:
- Use authIndexType=service - Specify journey explicitly
- Generate secure state/nonce - Use crypto.randomBytes(32)
- Validate state on callback - Prevent CSRF attacks
- Version journeys - Blue-green deployment for zero downtime
- Monitor journey metrics - Track success/failure rates
Common mistakes to avoid:
- Misspelling journey names (case-sensitive!)
- Not URL-encoding redirect_uri
- Skipping state validation
- Using weak state/nonce generation
- Not handling journey errors gracefully
Next steps:
- Configure your first hosted journey in ForgeRock Admin UI
- Test journey URL with all parameters
- Implement state/nonce validation on callback
- Set up journey versioning strategy
- Monitor authentication metrics
Related Articles: