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 Unauthorizedwithtype: 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...",
"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_verified": true,
"roles": ["support-manager"],
"tenant_id": "01j9p3kz5f00000000000000"
}
}
Token Comparison
| Flow | Token TTL | Stored as | One-use |
|---|---|---|---|
| Email verification | 24 hours | Argon2id hash | Yes |
| Password reset | 1 hour | Argon2id hash | Yes |
| Invite | 72 hours | Argon2id hash | Yes |
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
// 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
| Scenario | HTTP | type URI suffix |
|---|---|---|
| Verification token expired | 401 | token-expired |
| Verification token invalid | 401 | unauthorized |
| Reset token expired | 401 | token-expired |
| Reset token invalid | 401 | unauthorized |
| Invite token expired | 401 | token-expired |
| Password too short (< 12) | 400 | validation-error (code: TOO_SHORT) |
| Breached password | 400 | validation-error (code: BREACHED_PASSWORD) |
| Resend rate limit exceeded | 429 | rate-limit-exceeded |