diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnv.java b/src/main/java/com/google/firebase/fpnv/FirebasePnv.java
new file mode 100644
index 000000000..e963289be
--- /dev/null
+++ b/src/main/java/com/google/firebase/fpnv/FirebasePnv.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.fpnv;
+
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.fpnv.internal.FirebasePnvTokenVerifier;
+import com.google.firebase.internal.FirebaseService;
+
+/**
+ * This class is the entry point for the Firebase Phone Number Verification (FPNV) service.
+ *
+ * You can get an instance of {@link FirebasePnv} via {@link #getInstance()},
+ * or {@link #getInstance(FirebaseApp)} and then use it.
+ */
+public final class FirebasePnv {
+ private static final String SERVICE_ID = FirebasePnv.class.getName();
+ private final FirebasePnvTokenVerifier tokenVerifier;
+
+ private FirebasePnv(FirebaseApp app) {
+ this.tokenVerifier = new FirebasePnvTokenVerifier(app);
+ }
+
+ /**
+ * Gets the {@link FirebasePnv} instance for the default {@link FirebaseApp}.
+ *
+ * @return The {@link FirebasePnv} instance for the default {@link FirebaseApp}.
+ */
+ public static FirebasePnv getInstance() {
+ return getInstance(FirebaseApp.getInstance());
+ }
+
+ /**
+ * Gets the {@link FirebasePnv} instance for the specified {@link FirebaseApp}.
+ *
+ * @return The {@link FirebasePnv} instance for the specified {@link FirebaseApp}.
+ */
+ public static synchronized FirebasePnv getInstance(FirebaseApp app) {
+ FirebaseFpnvService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID,
+ FirebaseFpnvService.class);
+ if (service == null) {
+ service = ImplFirebaseTrampolines.addService(app, new FirebaseFpnvService(app));
+ }
+ return service.getInstance();
+ }
+
+ /**
+ * Verifies a Firebase Phone Number Verification token (FPNV JWT).
+ *
+ * @param fpnvJwt The FPNV JWT string to verify.
+ * @return A verified {@link FirebasePnvToken}.
+ * @throws FirebasePnvException If verification fails.
+ */
+ public FirebasePnvToken verifyToken(String fpnvJwt) throws FirebasePnvException {
+ return this.tokenVerifier.verifyToken(fpnvJwt);
+ }
+
+ private static class FirebaseFpnvService extends FirebaseService {
+ FirebaseFpnvService(FirebaseApp app) {
+ super(SERVICE_ID, new FirebasePnv(app));
+ }
+ }
+}
+
+
+
+
+
diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java
new file mode 100644
index 000000000..b5dc1fac4
--- /dev/null
+++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvErrorCode.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.fpnv;
+
+/**
+ * Error codes that are used in {@link FirebasePnv}.
+ */
+public enum FirebasePnvErrorCode {
+ INVALID_ARGUMENT,
+ INVALID_TOKEN,
+ TOKEN_EXPIRED,
+ INTERNAL_ERROR,
+ SERVICE_ERROR,
+}
diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java
new file mode 100644
index 000000000..db16f547d
--- /dev/null
+++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvException.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.fpnv;
+
+/**
+ * Generic exception related to Firebase Phone Number Verification.
+ * Check the error code and message for more
+ * details.
+ */
+public class FirebasePnvException extends Exception {
+ private final FirebasePnvErrorCode errorCode;
+
+ /**
+ * Exception that created from {@link FirebasePnvErrorCode},
+ * {@link String} message and {@link Throwable} cause.
+ *
+ * @param errorCode {@link FirebasePnvErrorCode}
+ * @param message {@link String}
+ * @param cause {@link Throwable}
+ */
+ public FirebasePnvException(
+ FirebasePnvErrorCode errorCode,
+ String message,
+ Throwable cause
+ ) {
+ super(message, cause);
+ this.errorCode = errorCode;
+ }
+
+ /**
+ * Exception that created from {@link FirebasePnvErrorCode} and {@link String} message.
+ *
+ * @param errorCode {@link FirebasePnvErrorCode}
+ * @param message {@link String}
+ */
+ public FirebasePnvException(
+ FirebasePnvErrorCode errorCode,
+ String message
+ ) {
+ this(errorCode, message, null);
+ }
+
+ public FirebasePnvErrorCode getFpnvErrorCode() {
+ return errorCode;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java
new file mode 100644
index 000000000..e54b1eb6d
--- /dev/null
+++ b/src/main/java/com/google/firebase/fpnv/FirebasePnvToken.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.fpnv;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Represents a verified Firebase Phone Number Verification token.
+ */
+public class FirebasePnvToken {
+ private final Map claims;
+
+ public FirebasePnvToken(Map claims) {
+ checkArgument(claims != null && claims.containsKey("sub"),
+ "Claims map must at least contain sub");
+ this.claims = ImmutableMap.copyOf(claims);
+ }
+
+ /**
+ * Returns the issuer identifier for the issuer of the response.
+ */
+ public String getIssuer() {
+ return (String) claims.get("iss");
+ }
+
+ /**
+ * Returns the phone number of the user.
+ * This corresponds to the 'sub' claim in the JWT.
+ */
+ public String getPhoneNumber() {
+ return (String) claims.get("sub");
+ }
+
+ /**
+ * Returns the audience for which this token is intended.
+ */
+ public List getAudience() {
+ Object audience = claims.get("aud");
+ if (audience instanceof String) {
+ return ImmutableList.of((String) audience);
+ } else if (audience instanceof List) {
+ // The nimbus-jose-jwt library should provide a List, but we copy it
+ // to an immutable list for safety and to prevent modification.
+ @SuppressWarnings("unchecked")
+ List audienceList = (List) audience;
+ return ImmutableList.copyOf(audienceList);
+ }
+ return ImmutableList.of();
+ }
+
+ /**
+ * Returns the expiration time in seconds since the Unix epoch.
+ */
+ public long getExpirationTime() {
+ return ((java.util.Date) claims.get("exp")).getTime();
+ }
+
+ /**
+ * Returns the issued-at time in seconds since the Unix epoch.
+ */
+ public long getIssuedAt() {
+ return ((java.util.Date) claims.get("iat")).getTime();
+ }
+
+ /**
+ * Returns the entire map of claims.
+ */
+ public Map getClaims() {
+ return claims;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java
new file mode 100644
index 000000000..a3cf78e2e
--- /dev/null
+++ b/src/main/java/com/google/firebase/fpnv/internal/FirebasePnvTokenVerifier.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.fpnv.internal;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.ImplFirebaseTrampolines;
+import com.google.firebase.fpnv.FirebasePnvErrorCode;
+import com.google.firebase.fpnv.FirebasePnvException;
+import com.google.firebase.fpnv.FirebasePnvToken;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
+import com.nimbusds.jose.proc.BadJOSEException;
+import com.nimbusds.jose.proc.JWSKeySelector;
+import com.nimbusds.jose.proc.JWSVerificationKeySelector;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.jwt.proc.DefaultJWTProcessor;
+import com.nimbusds.jwt.proc.ExpiredJWTException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.text.ParseException;
+import java.util.Date;
+import java.util.Objects;
+
+/**
+ * Internal class to verify FPNV tokens.
+ */
+public class FirebasePnvTokenVerifier {
+ private static final String FPNV_JWKS_URL = "https://fpnv.googleapis.com/v1beta/jwks";
+ private static final String HEADER_TYP = "JWT";
+
+ private final String projectId;
+ private final DefaultJWTProcessor jwtProcessor;
+
+ /**
+ * Create {@link FirebasePnvTokenVerifier} for internal purposes.
+ *
+ * @param app The {@link FirebaseApp} to get a FirebaseAuth instance for.
+ */
+ public FirebasePnvTokenVerifier(FirebaseApp app) {
+ this.projectId = getProjectId(app);
+ this.jwtProcessor = createJwtProcessor();
+ }
+
+ /**
+ * Main method that do.
+ * - Explicitly verify the header
+ * - Verify Signature and Structure
+ * - Verify Claims (Issuer, Audience, Expiration)
+ * - Construct Token Object
+ *
+ * @param token String input data
+ * @return {@link FirebasePnvToken}
+ * @throws FirebasePnvException Can throw {@link FirebasePnvException}
+ */
+ public FirebasePnvToken verifyToken(String token) throws FirebasePnvException {
+ checkArgument(!Strings.isNullOrEmpty(token), "FPNV token must not be null or empty");
+
+ try {
+ // Parse the token first to inspect header
+ SignedJWT signedJwt = SignedJWT.parse(token);
+
+ // Explicitly verify the header (alg & kid)
+ verifyHeader(signedJwt.getHeader());
+
+ // Verify Signature and Structure
+ JWTClaimsSet claims = jwtProcessor.process(signedJwt, null);
+
+ // Verify Claims (Issuer, Audience, Expiration)
+ verifyClaims(claims);
+
+ // Construct Token Object
+ return new FirebasePnvToken(claims.getClaims());
+ } catch (ParseException e) {
+ throw new FirebasePnvException(
+ FirebasePnvErrorCode.INVALID_TOKEN,
+ "Failed to parse JWT token: " + e.getMessage(),
+ e
+ );
+ } catch (ExpiredJWTException e) {
+ throw new FirebasePnvException(
+ FirebasePnvErrorCode.TOKEN_EXPIRED,
+ "FPNV token has expired.",
+ e
+ );
+ } catch (BadJOSEException | JOSEException e) {
+ throw new FirebasePnvException(FirebasePnvErrorCode.SERVICE_ERROR,
+ "Check your project: " + projectId + ". "
+ + e.getMessage(),
+ e
+ );
+ }
+ }
+
+ private void verifyHeader(JWSHeader header) throws FirebasePnvException {
+ // Check Algorithm (alg)
+ if (!JWSAlgorithm.ES256.equals(header.getAlgorithm())) {
+ throw new FirebasePnvException(
+ FirebasePnvErrorCode.INVALID_ARGUMENT,
+ "FPNV has incorrect 'algorithm'. Expected " + JWSAlgorithm.ES256.getName()
+ + " but got " + header.getAlgorithm());
+ }
+
+ // Check Key ID (kid)
+ if (Strings.isNullOrEmpty(header.getKeyID())) {
+ throw new FirebasePnvException(
+ FirebasePnvErrorCode.INVALID_ARGUMENT,
+ "FPNV has no 'kid' claim."
+ );
+ }
+ // Check Typ (typ)
+ if (Objects.isNull(header.getType()) || !HEADER_TYP.equals(header.getType().getType())) {
+ throw new FirebasePnvException(
+ FirebasePnvErrorCode.INVALID_ARGUMENT,
+ "FPNV has incorrect 'typ'. Expected " + HEADER_TYP
+ + " but got " + header.getType()
+ );
+ }
+
+ }
+
+ private void verifyClaims(JWTClaimsSet claims) throws FirebasePnvException {
+ // Verify Issuer
+ String issuer = claims.getIssuer();
+
+ if (Strings.isNullOrEmpty(issuer)) {
+ throw new FirebasePnvException(FirebasePnvErrorCode.INVALID_ARGUMENT,
+ "FPNV token has no 'iss' (issuer) claim.");
+ }
+
+ // Verify Audience
+ if (claims.getAudience() == null
+ || claims.getAudience().isEmpty()
+ || !claims.getAudience().contains(issuer)
+ ) {
+ throw new FirebasePnvException(FirebasePnvErrorCode.INVALID_TOKEN,
+ "Invalid audience. Expected to contain: "
+ + issuer + " but found: " + claims.getAudience()
+ );
+ }
+
+ // Verify Subject for emptiness / null
+ if (Strings.isNullOrEmpty(claims.getSubject())) {
+ throw new FirebasePnvException(
+ FirebasePnvErrorCode.INVALID_TOKEN,
+ "Token has an empty 'sub' (phone number)."
+ );
+ }
+ }
+
+ private DefaultJWTProcessor createJwtProcessor() {
+ DefaultJWTProcessor processor = new DefaultJWTProcessor<>();
+ try {
+ // Use JWKSourceBuilder instead of deprecated RemoteJWKSet
+ JWKSource keySource = JWKSourceBuilder
+ .create(new URL(FPNV_JWKS_URL))
+ .retrying(true) // Helper to retry on transient network errors
+ .build();
+
+ JWSKeySelector keySelector =
+ new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, keySource);
+ processor.setJWSKeySelector(keySelector);
+ } catch (MalformedURLException e) {
+ throw new RuntimeException("Invalid JWKS URL", e);
+ }
+ return processor;
+ }
+
+ private String getProjectId(FirebaseApp app) {
+ String projectId = ImplFirebaseTrampolines.getProjectId(app);
+ if (Strings.isNullOrEmpty(projectId)) {
+ throw new IllegalArgumentException("Project ID is required in FirebaseOptions.");
+ }
+ return projectId;
+ }
+}
diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java
new file mode 100644
index 000000000..f7f519827
--- /dev/null
+++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvErrorCodeTest.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.fpnv;
+
+import static org.junit.Assert.assertNotNull;
+
+import org.junit.Test;
+
+
+public class FirebasePnvErrorCodeTest {
+ @Test
+ public void testEnum() {
+ // Assert that all values exist
+ assertNotNull(FirebasePnvErrorCode.valueOf("INVALID_ARGUMENT"));
+ assertNotNull(FirebasePnvErrorCode.valueOf("INVALID_TOKEN"));
+ assertNotNull(FirebasePnvErrorCode.valueOf("TOKEN_EXPIRED"));
+ assertNotNull(FirebasePnvErrorCode.valueOf("INTERNAL_ERROR"));
+ assertNotNull(FirebasePnvErrorCode.valueOf("SERVICE_ERROR"));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java
new file mode 100644
index 000000000..6be4ceea3
--- /dev/null
+++ b/src/test/java/com/google/firebase/fpnv/FirebasePnvTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.fpnv;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.fpnv.internal.FirebasePnvTokenVerifier;
+import com.google.firebase.internal.FirebaseProcessEnvironment;
+import com.google.firebase.testing.ServiceAccount;
+import com.google.firebase.testing.TestUtils;
+import java.lang.reflect.Field;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class FirebasePnvTest {
+ private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder()
+ .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream()))
+ .build();
+
+ @Mock
+ private FirebasePnvTokenVerifier mockVerifier;
+
+ private FirebasePnv firebasePnv;
+
+ @Before
+ public void setUp() throws Exception {
+ // noinspection resource
+ MockitoAnnotations.openMocks(this);
+
+ // Initialize Fpnv
+ FirebaseApp.initializeApp(firebaseOptions);
+ firebasePnv = FirebasePnv.getInstance();
+
+ // Inject the mock verifier
+ Field verifierField = FirebasePnv.class.getDeclaredField("tokenVerifier");
+ verifierField.setAccessible(true);
+ verifierField.set(firebasePnv, mockVerifier);
+ }
+
+ @After
+ public void tearDown() {
+ FirebaseProcessEnvironment.clearCache();
+ TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+ }
+
+ @Test
+ public void testGetInstance() {
+ FirebasePnv firebasePnv = FirebasePnv.getInstance();
+ assertNotNull(firebasePnv);
+ assertSame(firebasePnv, FirebasePnv.getInstance());
+ }
+
+ @Test
+ public void testGetInstanceForApp() {
+ FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions, "testGetInstanceForApp");
+ FirebasePnv firebasePnv = FirebasePnv.getInstance(app);
+ assertNotNull(firebasePnv);
+ assertSame(firebasePnv, FirebasePnv.getInstance(app));
+ }
+
+ @Test
+ public void testVerifyToken_DelegatesToVerifier() throws FirebasePnvException {
+ String testToken = "test.fpnv.token";
+ FirebasePnvToken expectedToken = mock(FirebasePnvToken.class);
+
+ when(mockVerifier.verifyToken(testToken)).thenReturn(expectedToken);
+
+ FirebasePnvToken result = firebasePnv.verifyToken(testToken);
+
+ assertEquals(expectedToken, result);
+ verify(mockVerifier, times(1)).verifyToken(testToken);
+ }
+
+ @Test
+ public void testVerifyToken_PropagatesException() throws FirebasePnvException {
+ String testToken = "bad.token";
+ FirebasePnvException error = new FirebasePnvException(
+ FirebasePnvErrorCode.INVALID_TOKEN,
+ "Bad token"
+ );
+
+ when(mockVerifier.verifyToken(testToken)).thenThrow(error);
+
+ FirebasePnvException e = assertThrows(FirebasePnvException.class, () ->
+ FirebasePnv.getInstance().verifyToken(testToken)
+ );
+ assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java b/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java
new file mode 100644
index 000000000..bab502dca
--- /dev/null
+++ b/src/test/java/com/google/firebase/fpnv/FpnvTokenVerifierTest.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.firebase.fpnv;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import com.google.firebase.FirebaseApp;
+import com.google.firebase.FirebaseOptions;
+import com.google.firebase.TestOnlyImplFirebaseTrampolines;
+import com.google.firebase.fpnv.internal.FirebasePnvTokenVerifier;
+import com.google.firebase.internal.FirebaseProcessEnvironment;
+import com.google.firebase.testing.ServiceAccount;
+import com.google.firebase.testing.TestUtils;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.MACSigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+import com.nimbusds.jose.proc.SecurityContext;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.jwt.proc.DefaultJWTProcessor;
+import com.nimbusds.jwt.proc.ExpiredJWTException;
+import java.lang.reflect.Field;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Arrays;
+import java.util.Date;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class FpnvTokenVerifierTest {
+ private static final FirebaseOptions firebaseOptions = FirebaseOptions.builder()
+ .setCredentials(TestUtils.getCertCredential(ServiceAccount.OWNER.asStream()))
+ .build();
+ private static final String PROJECT_ID = "mock-project-id";
+ private static final String ISSUER = "https://fpnv.googleapis.com/projects/" + PROJECT_ID;
+ private static final String[] AUD = new String[]{
+ ISSUER,
+ "https://google.com/projects/"
+ };
+
+ @Mock
+ private DefaultJWTProcessor mockJwtProcessor;
+
+ private FirebasePnvTokenVerifier verifier;
+ private KeyPair rsaKeyPair;
+ private ECKey ecKey;
+ private JWSHeader header;
+
+ @Before
+ public void setUp() throws Exception {
+ // noinspection resource
+ MockitoAnnotations.openMocks(this);
+
+ // Generate a real RSA key pair for signing test tokens
+ KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+ gen.initialize(2048);
+ rsaKeyPair = gen.generateKeyPair();
+
+ ecKey = new ECKeyGenerator(Curve.P_256).keyID("ec-key-id").generate();
+
+ // Initialize Verifier and inject mock processor
+ FirebaseApp firebaseApp = FirebaseApp.initializeApp(firebaseOptions);
+ verifier = new FirebasePnvTokenVerifier(firebaseApp);
+
+ Field processorField = FirebasePnvTokenVerifier.class.getDeclaredField("jwtProcessor");
+ processorField.setAccessible(true);
+ processorField.set(verifier, mockJwtProcessor);
+
+ // Create a valid ES256 token
+ header = new JWSHeader.Builder(JWSAlgorithm.ES256)
+ .keyID(ecKey.getKeyID())
+ .type(JOSEObjectType.JWT)
+ .build();
+ }
+
+ @After
+ public void tearDown() {
+ FirebaseProcessEnvironment.clearCache();
+ TestOnlyImplFirebaseTrampolines.clearInstancesForTest();
+ }
+
+ // --- Helper to create a signed JWT string ---
+ private String createToken(JWSHeader header, JWTClaimsSet claims) throws Exception {
+ SignedJWT jwt = new SignedJWT(header, claims);
+
+ // Sign based on the algorithm in the header
+ if (JWSAlgorithm.RS256.equals(header.getAlgorithm())) {
+ jwt.sign(new RSASSASigner(rsaKeyPair.getPrivate()));
+ } else if (JWSAlgorithm.HS256.equals(header.getAlgorithm())) {
+ jwt.sign(new MACSigner("12345678901234567890123456789012")); // 32-byte secret
+ } else if (JWSAlgorithm.ES256.equals(header.getAlgorithm())) {
+ jwt.sign(new ECDSASigner(ecKey.toECPrivateKey()));
+ }
+
+ return jwt.serialize();
+ }
+
+ @Test
+ public void testVerifyToken_Success() throws Exception {
+ Date now = new Date();
+ Date exp = new Date(now.getTime() + 3600 * 1000); // 1 hour valid
+
+ JWTClaimsSet claims = new JWTClaimsSet.Builder()
+ .issuer(ISSUER)
+ .audience(Arrays.asList(AUD))
+ .subject("+15551234567")
+ .issueTime(now)
+ .expirationTime(exp)
+ .build();
+
+ String tokenString = createToken(header, claims);
+
+ // 1. Mock the processor to return these claims (skipping real signature verification)
+ when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(claims);
+
+ // 2. Execute
+ FirebasePnvToken result = verifier.verifyToken(tokenString);
+
+ // 3. Verify
+ assertNotNull(result);
+ assertEquals("+15551234567", result.getPhoneNumber());
+ assertEquals(ISSUER, result.getIssuer());
+ }
+
+ @Test
+ public void testVerifyToken_Header_WrongAlgorithm() throws Exception {
+ // Create token with HS256 (HMAC) instead of ES256
+ JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256).build();
+ JWTClaimsSet claims = new JWTClaimsSet.Builder().build();
+
+ String tokenString = createToken(header, claims);
+
+ // Should fail at header check, before reaching the processor
+ FirebasePnvException e = assertThrows(FirebasePnvException.class, () ->
+ verifier.verifyToken(tokenString)
+ );
+
+ assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode());
+ assertTrue(e.getMessage().contains("algorithm"));
+ }
+
+ @Test
+ public void testVerifyToken_Header_MissingKeyId() throws Exception {
+ // ES256 but missing 'kid'
+ JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256).build();
+ JWTClaimsSet claims = new JWTClaimsSet.Builder().build();
+
+ String tokenString = createToken(header, claims);
+
+ FirebasePnvException e = assertThrows(FirebasePnvException.class, () ->
+ verifier.verifyToken(tokenString)
+ );
+
+ assertEquals(FirebasePnvErrorCode.INVALID_ARGUMENT, e.getFpnvErrorCode());
+ assertTrue(e.getMessage().contains("FPNV has no 'kid' claim"));
+ }
+
+ @Test
+ public void testVerifyToken_Claims_Expired() throws Exception {
+ JWTClaimsSet claims = new JWTClaimsSet.Builder()
+ .issuer(ISSUER)
+ .audience(ISSUER)
+ .subject("+1555")
+ .expirationTime(new Date(System.currentTimeMillis() + 10000))
+ .build();
+
+ String tokenString = createToken(header, claims);
+ ExpiredJWTException error = new ExpiredJWTException("Bad token");
+
+ // Mock processor returning the expired claims
+ when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenThrow(error);
+
+ FirebasePnvException e = assertThrows(FirebasePnvException.class, () ->
+ verifier.verifyToken(tokenString)
+ );
+
+ assertEquals(FirebasePnvErrorCode.TOKEN_EXPIRED, e.getFpnvErrorCode());
+ }
+
+ @Test
+ public void testVerifyToken_Claims_WrongAudience() throws Exception {
+ JWTClaimsSet badClaims = new JWTClaimsSet.Builder()
+ .issuer("https://wrong.com") // Wrong issuer
+ .audience(ISSUER)
+ .subject("+1555")
+ .expirationTime(new Date(System.currentTimeMillis() + 10000))
+ .build();
+
+ String tokenString = createToken(header, badClaims);
+ when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(badClaims);
+
+ FirebasePnvException e = assertThrows(FirebasePnvException.class, () ->
+ verifier.verifyToken(tokenString)
+ );
+
+ assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode());
+ assertTrue(e.getMessage().contains("Invalid audience."));
+ }
+
+ @Test
+ public void testVerifyToken_Claims_NoSubject() throws Exception {
+ JWTClaimsSet noSubClaims = new JWTClaimsSet.Builder()
+ .issuer(ISSUER)
+ .audience(ISSUER)
+ .expirationTime(new Date(System.currentTimeMillis() + 10000))
+ .build(); // Missing subject
+
+ String tokenString = createToken(header, noSubClaims);
+ when(mockJwtProcessor.process(any(SignedJWT.class), any())).thenReturn(noSubClaims);
+
+ FirebasePnvException e = assertThrows(FirebasePnvException.class, () ->
+ verifier.verifyToken(tokenString)
+ );
+
+ assertEquals(FirebasePnvErrorCode.INVALID_TOKEN, e.getFpnvErrorCode());
+ assertTrue(e.getMessage().contains("Token has an empty 'sub' (phone number)"));
+ }
+}