---
title: "Microsoft SSO User IDs Are Not UUIDs — Azure Object IDs vs Custom ID Formats"
aliases: [ms-sso-ids, azure-ad-non-uuid, microsoft-user-id-format]
tags: [azure-ad, msal, sso, uuid, gotcha, auth, mongodb, fastapi]
sources:
  - "daily/2026-05-06.md"
created: 2026-05-06
updated: 2026-05-06
---

# Microsoft SSO User IDs Are Not UUIDs — Azure Object IDs vs Custom ID Formats

Microsoft SSO user identifiers from MSAL/Azure AD do not follow the UUID format. IDs can look like `ms-4n0T2x-aW4w1pO1OKYNf` — a custom alphanumeric format. Any code that validates user IDs as UUIDs (regex, Pydantic `UUID` type, MongoDB ObjectId coercion) will silently fail for SSO users, blocking login, profile creation, or data access.

## Key Takeaways

- Azure AD `objectId` IS a proper UUID (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) — but it may be transformed by the application into a custom format
- App-level IDs derived from SSO tokens (e.g. with a `ms-` prefix) are **not** UUIDs
- Any `re.match(UUID_PATTERN, user_id)` check, Pydantic `field: UUID`, or `ObjectId(user_id)` will raise/return None for non-UUID IDs
- Use `str` for user ID fields — never coerce to UUID/ObjectId unless you fully control ID generation
- The failure mode is silent: ID validation returns `None`, user is treated as "not found", and downstream code (email, notifications, access checks) never runs

## Details

### The ID Format Mismatch

```python
# Standard UUID pattern used in many codebases
UUID_PATTERN = re.compile(
    r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
    re.IGNORECASE
)

# Microsoft Azure AD objectId (direct) — IS a UUID ✓
azure_object_id = "a3d8e2f1-4b5c-6d7e-8f9a-0b1c2d3e4f5a"

# App-transformed SSO ID — NOT a UUID ✗
app_sso_id = "ms-4n0T2x-aW4w1pO1OKYNf"

# Validation fails silently
if not UUID_PATTERN.match(app_sso_id):
    return None  # SSO user is treated as "not found"
```

### Where This Breaks

**FastAPI path parameters with UUID type:**
```python
# ❌ FastAPI raises 422 Unprocessable Entity for non-UUID IDs
@app.get("/users/{user_id}")
async def get_user(user_id: UUID):  # rejects "ms-4n0T2x-..."
    ...

# ✅ Use str, validate business rules separately
@app.get("/users/{user_id}")
async def get_user(user_id: str):
    ...
```

**Pydantic model UUID fields:**
```python
from pydantic import BaseModel
from uuid import UUID

# ❌ Pydantic v2 raises ValidationError for non-UUID string
class UserRef(BaseModel):
    user_id: UUID  # breaks for SSO users

# ✅ Keep as str
class UserRef(BaseModel):
    user_id: str
```

**MongoDB ObjectId coercion:**
```python
from bson import ObjectId

# ❌ ObjectId() raises InvalidId for non-24-char-hex strings
user = await db.users.find_one({"_id": ObjectId(user_id)})

# ✅ Store user_id as a plain string field, not as _id ObjectId
user = await db.users.find_one({"user_id": user_id})
```

### Azure AD objectId vs App-Level IDs

| Source | Format | Example | Is UUID? |
|--------|--------|---------|----------|
| Azure AD `objectId` (raw) | UUID v4 | `a3d8e2f1-4b5c-...` | ✓ Yes |
| MSAL `homeAccountId` | `<oid>.<tid>` | `a3d8e2f1...tenant123` | ✗ Partial |
| App-transformed ID | Custom | `ms-4n0T2x-aW4w1pO1OKYNf` | ✗ No |
| Local user IDs | UUID or ObjectId | Depends on stack | Controlled |

### Recommendation

```python
# Store SSO users with a clear prefix and treat user_id as opaque string
async def get_or_create_sso_user(token_claims: dict) -> dict:
    azure_oid = token_claims["oid"]  # Azure objectId — UUID format
    
    # Either use azure_oid directly (valid UUID), or apply a prefix
    # but be CONSISTENT across the codebase
    user_id = azure_oid  # ✅ use raw Azure objectId — it IS a UUID
    
    user = await db.users.find_one({"user_id": user_id})
    if not user:
        await db.users.insert_one({
            "user_id": user_id,  # stored as str
            "email": token_claims["preferred_username"],
            "source": "azure_sso",
        })
    return user
```

> [!warning] The silent failure pattern
> When ID validation fails in middleware or a helper function and returns `None`, the caller often proceeds with `None` as a user reference. This causes `find_one({"user_id": None})` — which may return an unrelated document or None — rather than raising an auth error. Always fail loudly on invalid IDs.

## Related

- [[wiki/concepts/azure-ad-yaml-allowlist-pattern]] — Azure AD auth + YAML authZ pattern used across Oliver projects
- [[wiki/concepts/msal-vanilla-js-pkce]] — MSAL.js PKCE flow that produces the token claims
- [[wiki/tech-patterns/azure-ad-msal-auth]] — Oliver standard SSO pattern

## Sources

- [[daily/2026-05-06.md]] — BAIC dashboard: SSO users with `ms-` prefixed IDs failing UUID validation in route handlers
