---
title: "UAE Pass Integration in .NET: From the Token Call to Passing Assessment"
description: "UAE Pass is OAuth 2.0 until the token call, which takes its parameters in the query string and returns no id_token. The staging traps, a working C# client, what the SOP levels actually mean, why linking must key on UUID — and the design and copy rules that decide how many rounds of assessment you go through."
canonical: "https://www.razi.pro/blog/uae-pass-integration-dotnet"
date: "2026-09-08"
tags: ["UAEPass", "OAuth", ".NET", "Authentication", "Security", "API"]
source: "razi.pro"
---

# UAE Pass Integration in .NET: From the Token Call to Passing Assessment

UAE Pass is an OAuth 2.0 provider, and that sentence will cost you a day if you believe all of it.

The authorization step is ordinary. The token step is not. Point a stock .NET OAuth handler at it and the exchange fails, in a way that reads like a credentials problem rather than a protocol one.

Then, once the code works, you discover the code was the easy part. Assessment does not review your architecture. It reviews your button, your logo, and whether your error messages match a table you may not have read.

Here is both halves. Every endpoint, parameter and response below comes from the [official documentation](https://docs.uaepass.ae/overview). Where something is my own experience rather than a documented rule, it says so.

---

## The two environments

There are two, they share no data, and their credentials are not interchangeable.

| Endpoint | Staging | Production |
|---|---|---|
| Authorization | `https://stg-id.uaepass.ae/idshub/authorize` | `https://id.uaepass.ae/idshub/authorize` |
| Token | `https://stg-id.uaepass.ae/idshub/token` | `https://id.uaepass.ae/idshub/token` |
| User Info | `https://stg-id.uaepass.ae/idshub/userinfo` | `https://id.uaepass.ae/idshub/userinfo` |
| Logout | `https://stg-id.uaepass.ae/idshub/logout` | `https://id.uaepass.ae/idshub/logout` |

Put these in configuration, not in code. A hard-coded `stg-` prefix that survives into production is a bad afternoon.

## Staging will waste your first day. Here is how not to let it

The documentation asks for a staging user and the **staging mobile app** on your device. The production UAE Pass app will not authenticate a staging user, and this is the single most common way to lose a morning: you are testing correct code against the wrong app.

Three more things, in the order they will bite you.

**The staging app cannot upgrade your own account.** The verification options it offers — Face ID and Kiosk — do not work in staging. This is documented, in a red warning box, and it is easy to skim past:

> Kindly note you cannot upgrade the staging UAE PASS account using the Staging UAE PASS App. The FACE ID verification option and Kiosk option will not work in staging UAE PASS app. To upgrade/verify a staging UAE PASS Account you need to use the Staging Self Care Web Portal of UAE PASS.

That portal is at [stg.uaepass.ae](https://stg.uaepass.ae). You log in with the basic account, press **Upgrade**, change the User Type field, and log out and back in for the change to take. If you have no Emirates ID to hand, the documentation explicitly permits a dummy EID value in staging.

**The upgrade is one-way.** From the same page:

> Do not downgrade an account from SOP3 to SOP2 or SOP1. The only way to downgrade an account would be through deleting the account from Staging UAE PASS Mobile App and re-creating a basic account again.

The practical consequence is worth planning for: **create one staging account per SOP level before you upgrade any of them.** If you upgrade your only test user to SOP3 and then need to see how your application behaves for an unverified SOP1 user, you are deleting the account in the mobile app and starting again. That restriction is still in the documentation as of this writing, so assume it holds.

**One that is not documented, from my own integration:** the staging Android app was not delivering push notifications at all. The login would reach the push-confirmation step and simply sit there, with nothing arriving on the device. It was not my redirect and it was not my client — it was the staging app, and UAE Pass fixed it later. I do not know whether it can recur. I mention it only so that if you have checked your redirect URI three times and the notification never arrives, you know that "the problem is not on your side" is a real possibility worth raising with them rather than a comforting excuse.

## The authorization call

This part behaves the way you expect:

```http
https://stg-id.uaepass.ae/idshub/authorize
  ?response_type=code
  &client_id=sandbox_stage
  &scope=urn:uae:digitalid:profile:general
  &state=HnlHOJTkTb66Y5H
  &redirect_uri=https://stg-selfcare.uaepass.ae
  &acr_values=urn:safelayer:tws:policies:authentication:level:low
```

`acr_values` selects the authentication level using Safelayer policy URNs rather than anything OIDC-standard.

`scope` decides what you get back, and it is where visitor support is won or lost. A citizen or resident flow uses `urn:uae:digitalid:profile:general`. If you also need visitors, the documentation requires the extra scopes **on the first authentication call**:

```
scope=urn:uae:digitalid:profile:general
      urn:uae:digitalid:profile:general:profileType
      urn:uae:digitalid:profile:general:unifiedId
```

Space-separated, in one parameter. You cannot add them later in the exchange. The profile you get is decided by what you asked for at the door.

## The token call is not standard OAuth 2.0

Here is the part that breaks libraries.

RFC 6749 puts the token request parameters in an `application/x-www-form-urlencoded` request body. UAE Pass documents them in the **query string**, with a `multipart/form-data` content type:

```bash
curl --location --request POST \
  'https://stg-id.uaepass.ae/idshub/token?grant_type=authorization_code&redirect_uri=https://stg-selfcare.uaepass.ae&code=bfe96299-83f4-3ee9-80e4-56c24f5265d3' \
  --header 'Content-Type: multipart/form-data' \
  --header 'Authorization: Basic c2FuZGJveF9zdGFnZTpzYW5kYm94X3N0YWdl'
```

That `Basic` value is base64 of `client_id:client_secret` — in the sandbox example, `sandbox_stage:sandbox_stage`.

This is why `Microsoft.AspNetCore.Authentication.OpenIdConnect` does not work against UAE Pass unmodified. The handler is correct: it puts `grant_type`, `code` and `redirect_uri` in the form body, because that is what the specification requires. UAE Pass reads them from the query string. Your parameters arrive somewhere the server is not looking, and the error you get back does not mention query strings.

So write the exchange yourself. It is about twenty lines:

```csharp
public sealed class UaePassClient
{
    private readonly HttpClient _http;
    private readonly UaePassOptions _options;

    public UaePassClient(HttpClient http, IOptions<UaePassOptions> options)
    {
        _http = http;
        _options = options.Value;
    }

    public async Task<UaePassToken> ExchangeCodeAsync(string code, CancellationToken ct)
    {
        // Query string, not form body. This is the whole trick.
        var url = QueryHelpers.AddQueryString(
            _options.BaseUrl + "/idshub/token",
            new Dictionary<string, string?>
            {
                ["grant_type"] = "authorization_code",
                ["redirect_uri"] = _options.RedirectUri,
                ["code"] = code,
            });

        using var request = new HttpRequestMessage(HttpMethod.Post, url);

        var credentials = Convert.ToBase64String(
            Encoding.UTF8.GetBytes(_options.ClientId + ":" + _options.ClientSecret));
        request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);

        // The documented call carries a multipart/form-data body with no fields.
        request.Content = new MultipartFormDataContent();

        using var response = await _http.SendAsync(request, ct);
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<UaePassToken>(cancellationToken: ct)
               ?? throw new InvalidOperationException("Empty token response from UAE Pass.");
    }
}
```

`QueryHelpers` lives in `Microsoft.AspNetCore.WebUtilities`. Register the client through `IHttpClientFactory`, and keep `BaseUrl`, `ClientId` and `ClientSecret` in `UaePassOptions` bound from configuration — that is what makes the staging-to-production switch a config change rather than a deployment.

## There is no id_token

The documented token response is this, in full:

```json
{
  "access_token": "67f2536e-07e6-37c1-967f-78562000a4f9",
  "scope": "urn:uae:digitalid:profile:general",
  "token_type": "Bearer",
  "expires_in": 3600
}
```

No `id_token`. Several third-party guides describe validating an ID token's signature, issuer and audience against UAE Pass. In the documented web-application flow there is no ID token to validate.

You cannot decode a JWT to learn who signed in. You call `/userinfo` with the bearer token, and that response is your identity.

## SOP levels are the authorization decision

`userinfo` returns `userType` as the string `SOP1`, `SOP2` or `SOP3`. This is not decoration. It is the difference between an authenticated session and a verified identity, and it belongs in your authorization logic.

| | **SOP1 — Basic** | **SOP2 — Advanced** | **SOP3 — Qualified** |
|---|---|---|---|
| Status | Unverified | Verified | Verified |
| Email & mobile | Verified by OTP | Verified by OTP | Verified by OTP |
| Emirates ID | Not verified — the account can exist without one, or with one the user declined to verify | Verified via SmartPass / Dubai ID, or Emirates ID PIN registration | Verified via finger or face biometrics |
| Service access | Limited | All services | All services |
| Sign Document | No | Yes — Advanced signature (4-digit PIN) | Yes — Qualified signature (6-character password) |
| Add Document | No | No | Yes |

The trap is SOP1. That account is fully authenticated — the person controls the email and the mobile — and entirely unverified as a legal identity. If your service needs to know *who* someone is rather than merely that they came back, checking `userType` is the check. There is nothing else in the response that tells you.

## What /userinfo actually returns

Model it defensively, because the shape changes with the user.

```csharp
public sealed record UaePassProfile
{
    [JsonPropertyName("uuid")]        public required string Uuid { get; init; }
    [JsonPropertyName("userType")]    public required string UserType { get; init; }

    // Citizens and residents carry an Emirates ID. Visitors do not.
    [JsonPropertyName("idn")]         public string? EmiratesId { get; init; }

    // Visitor-only, and only if you asked for the extra scopes up front.
    [JsonPropertyName("unifiedID")]   public string? UnifiedId { get; init; }
    [JsonPropertyName("profileType")] public string? ProfileType { get; init; }

    [JsonPropertyName("spuuid")]      public string? SpUuid { get; init; }
    [JsonPropertyName("firstnameEN")] public string? FirstNameEn { get; init; }
    [JsonPropertyName("lastnameEN")]  public string? LastNameEn { get; init; }
    [JsonPropertyName("email")]       public string? Email { get; init; }
    [JsonPropertyName("mobile")]      public string? Mobile { get; init; }
}
```

Four things about that payload.

**Do not key on `sub`.** Across the documented sample responses, `sub` appears in three different shapes: `UAEPASS/7a05992e-3244-49d3-bcbc-7894c8fca25e`, a bare UUID, and a 32-character hex string. `uuid` is stable and is what the guidelines tell you to store.

**Do not use `fullnameEN`.** One documented sample returns `"Ram,,,,ABC,,"` — a comma-joined field with empty positions where name parts are absent. Build the display name from `firstnameEN` and `lastnameEN`, and treat the Arabic variants the same way.

**Visitors have no `idn`.** They carry `unifiedID` and `profileType` instead. If your Emirates ID column is `NOT NULL`, visitor logins fail at persistence — long after authentication succeeded — which presents as a database bug rather than as a scope decision made three steps earlier.

**The attribute set is not fixed.** The documentation notes the response "may change as per the scope and list of attributes allowed to share with your application". Depending on scope you may also see `spuuid`, `idType`, `gender`, `nationalityEN`/`nationalityAR`, `titleEN`/`titleAR`, and `acr`/`amr`. Treat everything except `uuid` and `userType` as optional.

## Account linking, and the rules assessment applies to it

Linking a UAE Pass identity to an account that already exists in your system is a one-time reconciliation, and it is the decision most expensive to get wrong — a bad key is only discovered months later, by which time the wrong rows are already linked.

The documentation is direct about the key:

> Linking should be done based on **UUID** and **Emirates ID** only, since UAEPASS provides an option to change the Email and Mobile in UAEPASS account.

Storing the UUID is not optional. The Standard Implementation Guidelines list it as a requirement the onboarding team checks: the UUID shared by UAE Pass "needs to be stored mandatorily" after linking or registration.

The same guidelines add four constraints that are easy to violate with a reasonable-looking design:

- **Keep the two login flows separate.** A "Sign in with UAE Pass" flow must not be merged into your existing local login or registration flow. They should be independent paths.
- **The linking attribute must be unique and verified** — no possible duplicates across your user records.
- **Do not link across verification states.** Linking a local *unverified* account to a UAE Pass *verified* account (SOP2/SOP3), or a local *verified* account to a UAE Pass *unverified* one (SOP1), is explicitly discouraged.
- **Email alone is not recommended as the unique attribute**, even where it is technically permitted — only as a last resort when no better identifier exists.

Every other attribute is a snapshot. The documentation instructs that they be compared and updated at every login, because the user can change them at any time. So refresh name, email and mobile on each sign-in, and never key on them.

Decide up front what happens when the match is ambiguous — the documentation covers the case where more than one local record shares a single verified Emirates ID.

## What assessment actually rejects

This is the part no integration guide prepares you for, and it is where my own submissions came back.

Almost none of it is code. Assessment is a review of your user-facing surface against UAE Pass's published guidelines, and the reviewers are strict in a way that surprises engineers.

**Your text must match theirs, exactly.** UAE Pass publishes the required copy for every error scenario in both English and Arabic — unverified SOP1 users, registered-users-only services, user cancellation, nationals-only services, and generic failures. These are not suggestions to paraphrase. The SOP1 rejection message, for instance, is specified as:

> You are not eligible to access this service. Your account is either not upgraded or you have a visitor account. Please contact &lt;SP Name&gt; to access the services.

Note the `&lt;SP Name&gt;` placeholder: your entity's name goes there, spelled the way your entity is actually registered. Getting your own organisation's name wrong, or referring to UAE Pass by an informal variant, is a finding. The full bilingual table is in the [Text Message Guidelines](https://docs.uaepass.ae/guidelines/design-guidelines/text-message-guidelines) — use it as the source rather than writing your own copy and translating it.

**Handle the documented cancellation codes.** A user backing out of the login returns `error=invalid_request`, `error=login_required`, `error=access_denied` or `error=cancelledOnApp`. All four mean the same thing to your user and should produce the specified cancellation message, not a stack trace and not a generic failure page.

**Use the official button, unmodified.** The design guidelines are blunt: do not create your own button styles. Pick one of the provided styles that suits your platform. A hand-rolled button that merely looks similar is a finding.

**Check the logo asset before you ship it.** In my case the supplied SVG had a font problem — the wordmark depended on a typeface that did not resolve, so it rendered incorrectly on our pages while looking fine in the design tool. Render every supplied asset in an actual browser, on a machine that does not have your design fonts installed, before submission.

**Every link and button must work.** Reviewers click all of them. A placeholder terms-of-service link or a dead footer item is enough to send the submission back.

None of this is difficult. All of it is invisible until someone rejects you for it, and each round trip costs calendar time you do not control — which is the real argument for reading the guidelines before you build the screens rather than after.

## A checklist

Before you write any code:

- Staging user created, and the **staging** mobile app on the test device.
- One staging account per SOP level you need to test, created **before** any upgrade — the upgrade is one-way.
- Upgrades done at [stg.uaepass.ae](https://stg.uaepass.ae), not in the app.
- Endpoints in configuration, per environment.
- Decided whether you support visitors — it changes the `scope` on your very first call.

While integrating:

- Token parameters in the **query string**, `multipart/form-data` content type, Basic auth header.
- No `id_token`: identity comes from `/userinfo`.
- Keyed on `uuid`, never on `sub`, never on email or mobile.
- `idn` nullable, because visitors have none.
- Display names from `firstnameEN` and `lastnameEN`, never `fullnameEN`.
- `userType` checked wherever a verified identity is genuinely required.
- UAE Pass login flow kept separate from your local login flow.

Before assessment:

- Error copy taken verbatim from the Text Message Guidelines, English and Arabic, with your registered entity name in the placeholder.
- All four cancellation error codes handled.
- Official button asset, unmodified; logo rendered and checked in a real browser.
- Every link and button on the flow clicked and working.
- No sandbox credential anywhere in the production build.

---

The authorization redirect is the easy half. The token call, the missing ID token and the choice of linking key decide whether the integration is still correct in a year — and the guidelines decide how many rounds of assessment it takes to go live at all.
