Skip to content

Native app sign-in

The Nametag mobile apps use their own sign-in endpoints. They return tokens instead of a browser cookie. If you’re writing your own client, use these endpoints rather than a personal API token so users never have to copy tokens around.

Call GET /api/server-info first to see which sign-in methods the server offers.

Terminal window
curl -X POST https://your-instance.example.com/api/auth/native/login \
-H "Content-Type: application/json" \
-d '{
"email": "you@example.com",
"password": "…",
"device": { "name": "Matto'\''s iPhone", "platform": "IOS", "appVersion": "1.0.0" }
}'
{
"accessToken": "ntsa_…",
"accessTokenExpiresAt": "2026-10-17T11:00:00.000Z",
"refreshToken": "ntsr_…",
"sessionId": "…",
"user": { "id": "…", "email": "you@example.com", "name": "Matto" }
}

platform is IOS or ANDROID. The device name is shown to the user under Settings > Security > Signed-in devices.

Send the access token as Authorization: Bearer ntsa_… on every request. It’s valid for one hour.

When a request returns 401 with code access_token_expired, exchange the refresh token for a new pair and retry:

Terminal window
curl -X POST https://your-instance.example.com/api/auth/native/refresh \
-H "Content-Type: application/json" \
-d '{ "refreshToken": "ntsr_…" }'

The response has the same shape as sign-in. Store the new refresh token straight away: the old one stops working. If a refresh response is lost and the app retries with the old token within 60 seconds, it gets a fresh pair. A refresh token presented again after that is treated as stolen, and the device is signed out.

Send one refresh at a time. If several requests fail with access_token_expired together, refresh once and retry them all with the new access token. Two refreshes sent in parallel can leave only one of the returned pairs valid.

A session stays signed in for as long as the app keeps refreshing. It expires after 90 days without a refresh. A 401 with code session_expired means the user has to sign in again.

Terminal window
curl -X POST https://your-instance.example.com/api/auth/native/logout \
-H "Authorization: Bearer ntsa_…"

Returns 204.

Terminal window
curl https://your-instance.example.com/api/user/sessions \
-H "Authorization: Bearer ntsa_..."

Returns { "sessions": [...] } with each device’s id, name, platform, appVersion, lastUsedAt, createdAt and current (true for the device making the request).

  • DELETE /api/user/sessions/{id} signs out one device (404 if it isn’t yours).
  • DELETE /api/user/sessions signs out every device except the one making the request.

These endpoints accept a browser session or a device token, not a personal API token.

When /api/server-info reports auth.oidc.enabled or auth.google, the app can sign in through the server’s identity provider. Self-hosters don’t need to configure anything new: the app uses the same OIDC client as the web.

  1. Generate a random state (16 to 128 characters), a code_verifier (43 to 128 characters, see RFC 7636) and code_challenge = BASE64URL(SHA256(code_verifier)).
  2. Open this URL in a private browser sheet (on iOS, ASWebAuthenticationSession with prefersEphemeralWebBrowserSession):
https://your-instance.example.com/api/auth/native/browser/start
?provider=oidc
&state=…
&code_challenge=…
&code_challenge_method=S256
&device_name=Matto%27s%20iPhone
&platform=IOS
&app_version=1.0.0
  1. When sign-in finishes, the sheet is redirected to nametag://auth/callback?code=ntc_…&state=…. Check that state matches what you sent.
  2. Exchange the code within 60 seconds:
Terminal window
curl -X POST https://your-instance.example.com/api/auth/native/browser/exchange \
-H "Content-Type: application/json" \
-d '{ "code": "ntc_…", "codeVerifier": "…" }'

The response has the same shape as password sign-in. A code works once; a wrong verifier also uses it up.

If sign-in doesn’t succeed, the sheet is redirected to nametag://auth/callback?error=…&state=…:

ErrorMeaning
invalid_requestA parameter on the start URL is missing or invalid.
provider_unavailableThat sign-in method isn’t enabled on this server.
email_not_verifiedThe identity provider hasn’t verified the email address.
account_linked_elsewhereThe account signs in with a different provider.
sign_in_failedSign-in was cancelled or failed.

The redirect address is always nametag://auth/callback; the server never redirects anywhere a request asks it to.

Error bodies have an English error message and a stable code. The one exception is a request with no credentials at all, which gets the plain { "error": "Unauthorized" } that every endpoint returns; treat a 401 without a code as “sign in”.

StatusCodeMeaning
400validation_failedThe request body is invalid. details lists the fields.
400not_a_device_sessionLogout was called without a device access token.
401invalid_codeThe single sign-on code is unknown, expired, already used, or the verifier doesn’t match.
401invalid_credentialsWrong email or password.
401access_token_expiredRefresh and retry.
401invalid_tokenThe bearer token is unknown or revoked.
401session_expiredSign in again.
403account_lockedToo many failed attempts. Try again in 30 minutes.
403email_not_verifiedVerify the email address first (hosted service only).
403password_login_disabledThis server only allows single sign-on.
413payload_too_largeThe request body is too large.
429rate_limitedWait for the number of seconds in Retry-After.
500internal_errorSomething went wrong on the server.
503service_unavailableThe server could not reach its database. Try again shortly.

All codes: Errors.

Resetting the password by email signs out every device and every browser. Changing it with PUT /api/user/password signs out every other device and browser, but a change made from the app keeps that app signed in.

  • Device name: 1 to 100 characters. App version: 1 to 32 characters.
  • Sign-in shares the web login rate limit. Refresh has its own, more generous limit.
  • Only SHA-256 hashes of the tokens are stored.