Skip to main content

Security Flows

Security flows handle the credential lifecycle outside of normal login: confirming ownership of an email address, recovering a forgotten password, and bringing new users into a tenant via invitation. All three are designed with anti-enumeration as a first-class requirement — an attacker cannot distinguish a registered email from an unregistered one by observing responses.


1. Email Verification

When a user registers or adds an email address, the IAM service sends a verification link. Sensitive operations — including money transfers — are gated behind email_verified: true.

Flow

1. POST /auth/register OR PATCH /users/:id (adding email)
→ IAM creates a random 32-byte token, stores Argon2id hash in DB
→ IAM sends email: "Verify your address" with link
https://app.septemcore.com/verify-email?token=<raw_token>
→ TTL: 24 hours

2. User clicks the link (token extracted from URL)

3. POST /auth/verify-email { "token": "<raw_token>" }
→ IAM hashes token, compares with DB (Argon2id constant-time)
→ Sets email_verified: true on the user record
→ Deletes the token

Resend verification

If the user did not receive the email or the link expired:

POST https://api.septemcore.com/v1/auth/request-verify-email
Authorization: Bearer <access_token>

Response 202 Accepted (no body). Rate-limited: maximum 3 requests per hour per user.

Verify email token

POST https://api.septemcore.com/v1/auth/verify-email
Content-Type: application/json

{
"token": "9f4a2b..."
}

Response 200 OK:

{
"email_verified": true
}

Token security:

  • 32 bytes of crypto/rand — 256 bits of entropy.
  • Stored as Argon2id hash — the raw token is not recoverable from the DB.
  • One-use: token deleted immediately after verification.
  • Expired token returns 401 Unauthorized with type: token-expired.

2. Password Reset

Allows a user with a verified email to set a new password without knowing the current one.

Password Reset Flow

1. POST /auth/request-reset { "email": "[email protected]" }
→ IAM checks if email exists
└── EXISTS: creates token (32-byte random), stores Argon2id hash, TTL 1 hour
sends email with link:
https://app.septemcore.com/reset-password?token=<raw_token>
└── NOT FOUND: does nothing silently
→ In BOTH cases: response is identical (202 Accepted, no body)
deliberate anti-enumeration — no timing difference

2. User clicks the link → frontend shows password entry form

3. POST /auth/reset-password { "token": "...", "password": "NewS3cure!" }
→ IAM verifies token (Argon2id constant-time)
→ Validates new password (NIST 800-63B: min 12 chars, breached-password check)
→ Sets new Argon2id-hashed password
→ Deletes all active refresh tokens for the user (forced logout on all devices)
→ Deletes the reset token

Request reset

POST https://api.septemcore.com/v1/auth/request-reset
Content-Type: application/json

{
"email": "[email protected]"
}

Response 202 Accepted (always, regardless of whether the email exists).

Submit new password

POST https://api.septemcore.com/v1/auth/reset-password
Content-Type: application/json

{
"token": "9f4a2b...",
"password": "NewS3cure-P@ss!"
}

Response 200 OK:

{
"message": "Password updated. All sessions have been signed out."
}

Session invalidation: All existing refresh tokens are revoked. The user must log in again on every device. This is the expected security behaviour after a password reset — an attacker who obtained an old refresh token can no longer use it.

Anti-enumeration implementation:

// services/iam/internal/auth/reset.go (simplified)
func (s *Service) RequestPasswordReset(ctx context.Context, email string) error {
user, err := s.repo.FindByEmail(ctx, email)
if err != nil {
// Perform a dummy Argon2id hash to equalise timing
// regardless of whether the user exists
_ = argon2.IDKey([]byte("dummy"), make([]byte, 16), 1, 64*1024, 4, 32)
return nil // return 202 always
}
token := generateSecureToken()
_ = s.repo.StoreResetToken(ctx, user.ID, argon2Hash(token), time.Hour)
_ = s.mailer.SendPasswordReset(ctx, user.Email, token)
return nil
}

3. Invite Flow

An existing user with the users.create permission can invite a new user to the tenant. Each invitation carries a pre-assigned role.

Invite Flow Steps

1. POST /auth/invite { "email": "[email protected]", "role_id": "..." }
→ IAM generates invite token (32-byte random)
→ Stores Argon2id hash with TTL 72 hours and role_id
→ Sends email: "You've been invited to <Tenant Name>"
with link: https://app.septemcore.com/accept-invite?token=<raw_token>

2. Bob clicks the link → frontend shows password setup form

3. POST /auth/accept-invite { "token": "...", "name": "Bob", "password": "..." }
→ IAM verifies token + checks TTL
→ Creates user (email_verified: true — trust the invite link as verification)
→ Assigns specified role_id
→ Issues JWT pair for the new user (auto-login after acceptance)
→ Deletes the invite token

Send invitation

POST https://api.septemcore.com/v1/auth/invite
Authorization: Bearer <access_token>
Content-Type: application/json

{
"email": "[email protected]",
"role_id": "01j9p6kx2e00000000000000"
}

Response 202 Accepted:

{
"invite_id": "inv_01j9p7...",
"email": "[email protected]",
"expires_at": "2026-04-18T10:00:00Z"
}

Anti-enumeration: If the email already has an active account in this tenant, the IAM service responds with 202 Accepted (no body) rather than a conflict error. Internally, a duplicate-invite notification is sent to the existing user.

Accept invitation

POST https://api.septemcore.com/v1/auth/accept-invite
Content-Type: application/json

{
"token": "9f4a2b...",
"name": "Bob Smith",
"password": "SecureP@ssw0rd!"
}

Response 201 Created:

{
"access_token": "eyJhbGciOiJFUzI1NiJ9...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "01j9p8...",
"email": "[email protected]",
"email_verified": true,
"roles": ["support-manager"],
"tenant_id": "01j9p3kz5f00000000000000"
}
}

Token Comparison

FlowToken TTLStored asOne-use
Email verification24 hoursArgon2id hashYes
Password reset1 hourArgon2id hashYes
Invite72 hoursArgon2id hashYes

All three use crypto/rand (32 bytes = 256 bits entropy) and Argon2id constant-time comparison. Raw tokens are never stored in the database.


SDK

import { useAuth } from '@platform/sdk-auth';

const { verifyEmail, resetPassword, inviteUser } = useAuth();

// Verify email (called from the link in the email)
await verifyEmail({ token: routeParams.token });

// Request password reset
await resetPassword.request({ email: '[email protected]' });

// Submit new password (from reset form)
await resetPassword.confirm({
token: routeParams.token,
password: newPassword,
});

// Invite a new user to the tenant
await inviteUser({
roleId: '01j9p6kx2e00000000000000',
});

Error Reference

ScenarioHTTPtype URI suffix
Verification token expired401token-expired
Verification token invalid401unauthorized
Reset token expired401token-expired
Reset token invalid401unauthorized
Invite token expired401token-expired
Password too short (< 12)400validation-error (code: TOO_SHORT)
Breached password400validation-error (code: BREACHED_PASSWORD)
Resend rate limit exceeded429rate-limit-exceeded