Skip to content

Add sign-in to a SPA or mobile app

This guide adds sign-in to an app whose code runs on the user’s device: a single-page app (SPA) in the browser, a mobile app, or a desktop app.

It’s the same trip as a web app’s: the browser goes to the auth server, the user signs in, and your app gets an authorization code it trades for tokens. The difference is that anyone can read your app’s code, so it can’t keep a client secret. It’s registered as a public client, with no secret at all, and PKCE is what ties each code to the app that asked for it.

  1. Register your app. In the admin console, open Admin, Clients and click New client. Enter a Client identifier, such as my-spa, leave Authorization code with PKCE on, and click Create client.

  2. Click your new client’s identifier. On Authentication, choose Public, click Save, and confirm with Save.

  3. On Redirect URIs, enter the address your app receives the code at, click Add, then Save:

    Your app Redirect URI
    A SPA A page of your app, such as https://app.example.com/callback
    A mobile app A URI with your app’s own scheme, such as com.example.app:/oauth2redirect
    A desktop app A loopback address, such as http://127.0.0.1/callback. Any port matches.
  4. A SPA only: on Web origins, enter your app’s origin, such as https://app.example.com, click Add, then Save. Without it, the browser blocks your app’s call to the token endpoint.

  5. When a user signs in, make a state, a nonce and a PKCE code verifier, with the challenge made from it, and keep them until the browser comes back. Then open the authorization request in the browser. A mobile or desktop app opens it in the system browser:

    GET /auth/authorize?client_id=my-spa&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&response_type=code&scope=openid%20email&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=af0ifjsldkj&nonce=n-0S6_WzA2Mj HTTP/1.1
    Host: auth.example.com
  6. The user signs in, and the browser comes back to your redirect URI with code, state and iss. Check that state is the one you kept and that iss is the auth server’s issuer, then redeem the code within 60 seconds. A public client sends its client_id and no secret:

    Terminal window
    curl -X POST https://auth.example.com/auth/token \
    -d grant_type=authorization_code \
    -d client_id=my-spa \
    -d code=SplxlOBeZQQYbYS6WxSbIA \
    -d redirect_uri=https://app.example.com/callback \
    -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
  7. Check the ID token as a web app does, nonce included, and the user is signed in as its sub. When the access token expires, refresh it with your client_id and no secret:

    Terminal window
    curl -X POST https://auth.example.com/auth/token \
    -d grant_type=refresh_token \
    -d client_id=my-spa \
    -d refresh_token=eyJhbGciOiJSUzI1NiIs...

A public client has no secret, so nothing it sends to the token endpoint proves who it is. PKCE makes up for that: your app sends a hash of a fresh secret with each authorization request, and the code is redeemed only with the secret itself. A code someone intercepts on its way back is no use without it.

So a public client always uses PKCE, and the auth server refuses a public client’s authorization request without a code_challenge. There’s no setting to turn it off. A public client also can’t use the client credentials flow, and a request that sends it a client_secret is refused with invalid_request. See Clients.

Your SPA calls the token endpoint from JavaScript, from another origin than the auth server’s, so the browser lets it read the answer only when the auth server allows that origin (CORS). The auth server allows every origin registered as a web origin, at /userinfo and /auth/logout too. A web origin is permitted server-wide, for every client, whichever client you register it on.

Keep the tokens where other sites can’t reach them. Keeping them in memory is the safest: the user signs in again when the page reloads, which single sign-on usually makes a redirect and back, with no password.

Open the authorization request in the system browser, never in a web view inside your app. RFC 8252 says a native app must not use one, since your app could read the password the user types into it. The system browser also shares the auth server’s session with every other app, so a user who’s already signed in isn’t asked for their password again.

A mobile app’s redirect URI uses a scheme of its own, such as com.example.app:/oauth2redirect, which the phone hands to your app. Use a reverse domain name you control, so no other app claims it. A custom scheme needs no host.

A desktop app can receive the code on a local server it starts on a free port. For an http redirect URI on 127.0.0.1, [::1] or localhost, the auth server ignores the port when it compares. RFC 8252 asks this for the two IP addresses, and Goiabada allows it for localhost too, as a convenience: register http://127.0.0.1/callback, and http://127.0.0.1:54321/callback matches. Prefer 127.0.0.1 to localhost. See loopback redirect URIs.

A public client gets a refresh token like any other, and it works the same way: every refresh hands your app a new refresh token and retires the one it sent, so store each new one before you use the rest of the answer. See Refresh tokens.